mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge feature-tests: schema coercion, tool sanitization, Codex auth export, enhanced test suite
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -112,6 +112,7 @@ app.log
|
||||
|
||||
# Backup directories
|
||||
app.__qa_backup/
|
||||
.app-build-backup-*/
|
||||
|
||||
# Production standalone build (created by scripts/prepublish.mjs)
|
||||
# Conflicts with Next.js App Router detection in dev (root app/ shadows src/app/)
|
||||
|
||||
@@ -114,6 +114,7 @@ npm run test:fixes # Fix verification tests
|
||||
|
||||
# With coverage
|
||||
npm run test:coverage
|
||||
npm run coverage:report
|
||||
|
||||
# E2E tests (requires Playwright)
|
||||
npm run test:e2e
|
||||
@@ -123,7 +124,13 @@ npm run lint
|
||||
npm run check
|
||||
```
|
||||
|
||||
Current test status: **368+ unit tests** covering:
|
||||
Coverage notes:
|
||||
|
||||
- `npm run test:coverage` measures source coverage for the main unit test suite, excludes `tests/**`, and includes `open-sse/**`
|
||||
- `npm run coverage:report` prints the detailed file-by-file report from the latest coverage run
|
||||
- `npm run test:coverage:legacy` preserves the older metric for historical comparison
|
||||
|
||||
Current test status: **968+ unit tests** covering:
|
||||
|
||||
- Provider translators and format conversion
|
||||
- Rate limiting, circuit breaker, and resilience
|
||||
|
||||
166
COVERAGE_PLAN.md
Normal file
166
COVERAGE_PLAN.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Test Coverage Plan
|
||||
|
||||
Last updated: 2026-03-28
|
||||
|
||||
## Baseline
|
||||
|
||||
There are multiple coverage numbers depending on how the report is computed. For planning, only one of them is useful.
|
||||
|
||||
| Metric | Scope | Statements / Lines | Branches | Functions | Notes |
|
||||
| -------------------- | ----------------------------------------------------- | -----------------: | -------: | --------: | --------------------------------------------------- |
|
||||
| Legacy | Old `npm run test:coverage` | 79.42% | 75.15% | 67.94% | Inflated: counts test files and excludes `open-sse` |
|
||||
| Diagnostic | Source-only, excluding tests and excluding `open-sse` | 68.16% | 63.55% | 64.06% | Useful only to isolate `src/**` |
|
||||
| Recommended baseline | Source-only, excluding tests and including `open-sse` | 56.95% | 66.05% | 57.80% | This is the project-wide baseline to improve |
|
||||
|
||||
The recommended baseline is the number to optimize against.
|
||||
|
||||
## Rules
|
||||
|
||||
- Coverage targets apply to source files, not to `tests/**`.
|
||||
- `open-sse/**` is part of the product and must remain in scope.
|
||||
- New code should not reduce coverage in touched areas.
|
||||
- Prefer testing behavior and branch outcomes over implementation details.
|
||||
- Prefer temp SQLite databases and small fixtures over broad mocks for `src/lib/db/**`.
|
||||
|
||||
## Current command set
|
||||
|
||||
- `npm run test:coverage`
|
||||
- Main source coverage gate for the unit test suite
|
||||
- Generates `text-summary`, `html`, `json-summary`, and `lcov`
|
||||
- `npm run coverage:report`
|
||||
- Detailed file-by-file report from the latest run
|
||||
- `npm run test:coverage:legacy`
|
||||
- Historical comparison only
|
||||
|
||||
## Milestones
|
||||
|
||||
| Phase | Target | Focus |
|
||||
| ------- | ---------------------: | ------------------------------------------------- |
|
||||
| Phase 1 | 60% statements / lines | Quick wins and low-risk utility coverage |
|
||||
| Phase 2 | 65% statements / lines | DB and route foundations |
|
||||
| Phase 3 | 70% statements / lines | Provider validation and usage analytics |
|
||||
| Phase 4 | 75% statements / lines | `open-sse` translators and helpers |
|
||||
| Phase 5 | 80% statements / lines | `open-sse` handlers and executor branches |
|
||||
| Phase 6 | 85% statements / lines | Harder edge cases, branch debt, regression suites |
|
||||
| Phase 7 | 90% statements / lines | Final sweep, gap closure, strict ratchet |
|
||||
|
||||
Branches and functions should ratchet upward with each phase, but the primary hard target is statements / lines.
|
||||
|
||||
## Priority hotspots
|
||||
|
||||
These files or areas offer the best return for the next phases:
|
||||
|
||||
1. `open-sse/handlers`
|
||||
- `chatCore.ts` at 7.57%
|
||||
- Overall directory at 29.07%
|
||||
2. `open-sse/translator/request`
|
||||
- Overall directory at 36.39%
|
||||
- Many translators are still near single-digit coverage
|
||||
3. `open-sse/translator/response`
|
||||
- Overall directory at 8.07%
|
||||
4. `open-sse/executors`
|
||||
- Overall directory at 36.62%
|
||||
5. `src/lib/db`
|
||||
- `models.ts` at 20.66%
|
||||
- `registeredKeys.ts` at 34.46%
|
||||
- `modelComboMappings.ts` at 36.25%
|
||||
- `settings.ts` at 46.40%
|
||||
- `webhooks.ts` at 33.33%
|
||||
6. `src/lib/usage`
|
||||
- `usageHistory.ts` at 21.12%
|
||||
- `usageStats.ts` at 9.56%
|
||||
- `costCalculator.ts` at 30.00%
|
||||
7. `src/lib/providers`
|
||||
- `validation.ts` at 41.16%
|
||||
8. Low-risk utility and API files for early gains
|
||||
- `src/shared/utils/upstreamError.ts`
|
||||
- `src/shared/utils/apiAuth.ts`
|
||||
- `src/lib/api/errorResponse.ts`
|
||||
- `src/app/api/settings/require-login/route.ts`
|
||||
- `src/app/api/providers/[id]/models/route.ts`
|
||||
|
||||
## Execution checklist
|
||||
|
||||
### Phase 1: 56.95% -> 60%
|
||||
|
||||
- [x] Fix coverage metric so it reflects source code instead of test files
|
||||
- [x] Keep a legacy coverage script for comparison
|
||||
- [x] Record the baseline and hotspots in-repo
|
||||
- [ ] Add focused tests for low-risk utilities:
|
||||
- `src/shared/utils/upstreamError.ts`
|
||||
- `src/shared/utils/fetchTimeout.ts`
|
||||
- `src/lib/api/errorResponse.ts`
|
||||
- `src/shared/utils/apiAuth.ts`
|
||||
- `src/lib/display/names.ts`
|
||||
- [ ] Add route tests for:
|
||||
- `src/app/api/settings/require-login/route.ts`
|
||||
- `src/app/api/providers/[id]/models/route.ts`
|
||||
|
||||
### Phase 2: 60% -> 65%
|
||||
|
||||
- [ ] Add DB-backed tests for:
|
||||
- `src/lib/db/modelComboMappings.ts`
|
||||
- `src/lib/db/settings.ts`
|
||||
- `src/lib/db/registeredKeys.ts`
|
||||
- [ ] Cover branch behavior in:
|
||||
- `src/lib/providers/validation.ts`
|
||||
- `src/app/api/v1/embeddings/route.ts`
|
||||
- `src/app/api/v1/moderations/route.ts`
|
||||
|
||||
### Phase 3: 65% -> 70%
|
||||
|
||||
- [ ] Add usage analytics tests for:
|
||||
- `src/lib/usage/usageHistory.ts`
|
||||
- `src/lib/usage/usageStats.ts`
|
||||
- `src/lib/usage/costCalculator.ts`
|
||||
- [ ] Expand route coverage for proxy management and settings branches
|
||||
|
||||
### Phase 4: 70% -> 75%
|
||||
|
||||
- [ ] Cover translator helpers and central translation paths:
|
||||
- `open-sse/translator/index.ts`
|
||||
- `open-sse/translator/helpers/*`
|
||||
- `open-sse/translator/request/*`
|
||||
- `open-sse/translator/response/*`
|
||||
|
||||
### Phase 5: 75% -> 80%
|
||||
|
||||
- [ ] Add handler-level tests for:
|
||||
- `open-sse/handlers/chatCore.ts`
|
||||
- `open-sse/handlers/responsesHandler.js`
|
||||
- `open-sse/handlers/imageGeneration.js`
|
||||
- `open-sse/handlers/embeddings.js`
|
||||
- [ ] Add executor branch coverage for provider-specific auth, retries, and endpoint overrides
|
||||
|
||||
### Phase 6: 80% -> 85%
|
||||
|
||||
- [ ] Merge more edge-case suites into the main coverage path
|
||||
- [ ] Increase function coverage for DB modules with weak constructor/helper coverage
|
||||
- [ ] Close branch gaps in `settings.ts`, `registeredKeys.ts`, `validation.ts`, and translator helpers
|
||||
|
||||
### Phase 7: 85% -> 90%
|
||||
|
||||
- [ ] Treat the remaining low-coverage files as blockers
|
||||
- [ ] Add regression tests for every uncovered production bug fixed during the push to 90%
|
||||
- [ ] Raise the coverage gate in CI only after the local baseline is stable for at least two consecutive runs
|
||||
|
||||
## Ratchet policy
|
||||
|
||||
Update `npm run test:coverage` thresholds only after the project actually exceeds the next milestone with a comfortable buffer.
|
||||
|
||||
Recommended ratchet sequence:
|
||||
|
||||
1. 55/60/55
|
||||
2. 60/62/58
|
||||
3. 65/64/62
|
||||
4. 70/66/66
|
||||
5. 75/70/72
|
||||
6. 80/75/78
|
||||
7. 85/80/84
|
||||
8. 90/85/88
|
||||
|
||||
Order is `statements-lines / branches / functions`.
|
||||
|
||||
## Known gap
|
||||
|
||||
The current coverage command measures the main Node unit suite and includes source reached from it, including `open-sse`. It does not yet merge Vitest coverage into a single unified report. That merge is worth doing later, but it is not a blocker for starting the 60% -> 80% climb.
|
||||
2081
README.cs.md
Normal file
2081
README.cs.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Coerce string-encoded numeric JSON Schema constraints to their proper types.
|
||||
* Some clients (Cursor, Cline, etc.) send e.g. "minimum": "1" instead of "minimum": 1,
|
||||
* which causes 400 errors on strict providers like Claude and OpenAI.
|
||||
* Shared sanitizers for tool payloads that arrive from IDEs/SDKs with
|
||||
* JSON Schema numeric constraints encoded as strings or invalid descriptions.
|
||||
*/
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const NUMERIC_SCHEMA_FIELDS = [
|
||||
"minimum",
|
||||
"maximum",
|
||||
@@ -18,117 +19,189 @@ const NUMERIC_SCHEMA_FIELDS = [
|
||||
"multipleOf",
|
||||
] as const;
|
||||
|
||||
export function coerceSchemaNumericFields(schema: any): any {
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
if (Array.isArray(schema)) return schema.map(coerceSchemaNumericFields);
|
||||
function isPlainObject(value: unknown): value is JsonRecord {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const result = { ...schema };
|
||||
function coerceNumericString(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return value;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}
|
||||
|
||||
function mapRecordValues(record: JsonRecord): JsonRecord {
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).map(([key, value]) => [key, coerceSchemaNumericFields(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeDescriptionValue(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return "";
|
||||
return typeof value === "string" ? value : String(value);
|
||||
}
|
||||
|
||||
export function coerceSchemaNumericFields(schema: unknown): unknown {
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map((entry) => coerceSchemaNumericFields(entry));
|
||||
}
|
||||
if (!isPlainObject(schema)) return schema;
|
||||
|
||||
const result: JsonRecord = { ...schema };
|
||||
|
||||
for (const field of NUMERIC_SCHEMA_FIELDS) {
|
||||
if (field in result && typeof result[field] === "string") {
|
||||
const num = Number(result[field]);
|
||||
if (!isNaN(num) && isFinite(num)) {
|
||||
result[field] = num;
|
||||
}
|
||||
if (field in result) {
|
||||
result[field] = coerceNumericString(result[field]);
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested schema structures
|
||||
if (result.properties && typeof result.properties === "object") {
|
||||
result.properties = Object.fromEntries(
|
||||
Object.entries(result.properties).map(([key, val]) => [key, coerceSchemaNumericFields(val)])
|
||||
);
|
||||
if (isPlainObject(result.properties)) {
|
||||
result.properties = mapRecordValues(result.properties);
|
||||
}
|
||||
if (result.items) {
|
||||
if (isPlainObject(result.patternProperties)) {
|
||||
result.patternProperties = mapRecordValues(result.patternProperties);
|
||||
}
|
||||
if (isPlainObject(result.definitions)) {
|
||||
result.definitions = mapRecordValues(result.definitions);
|
||||
}
|
||||
if (isPlainObject(result.$defs)) {
|
||||
result.$defs = mapRecordValues(result.$defs);
|
||||
}
|
||||
if (isPlainObject(result.dependentSchemas)) {
|
||||
result.dependentSchemas = mapRecordValues(result.dependentSchemas);
|
||||
}
|
||||
|
||||
if (result.items !== undefined) {
|
||||
result.items = coerceSchemaNumericFields(result.items);
|
||||
}
|
||||
if (result.additionalProperties && typeof result.additionalProperties === "object") {
|
||||
result.additionalProperties = coerceSchemaNumericFields(result.additionalProperties);
|
||||
}
|
||||
if (result.unevaluatedProperties && typeof result.unevaluatedProperties === "object") {
|
||||
result.unevaluatedProperties = coerceSchemaNumericFields(result.unevaluatedProperties);
|
||||
}
|
||||
if (Array.isArray(result.prefixItems)) {
|
||||
result.prefixItems = result.prefixItems.map((entry) => coerceSchemaNumericFields(entry));
|
||||
}
|
||||
if (Array.isArray(result.anyOf)) {
|
||||
result.anyOf = result.anyOf.map(coerceSchemaNumericFields);
|
||||
result.anyOf = result.anyOf.map((entry) => coerceSchemaNumericFields(entry));
|
||||
}
|
||||
if (Array.isArray(result.oneOf)) {
|
||||
result.oneOf = result.oneOf.map(coerceSchemaNumericFields);
|
||||
result.oneOf = result.oneOf.map((entry) => coerceSchemaNumericFields(entry));
|
||||
}
|
||||
if (Array.isArray(result.allOf)) {
|
||||
result.allOf = result.allOf.map(coerceSchemaNumericFields);
|
||||
result.allOf = result.allOf.map((entry) => coerceSchemaNumericFields(entry));
|
||||
}
|
||||
if (result.not && typeof result.not === "object") {
|
||||
if (isPlainObject(result.not)) {
|
||||
result.not = coerceSchemaNumericFields(result.not);
|
||||
}
|
||||
if (isPlainObject(result.if)) {
|
||||
result.if = coerceSchemaNumericFields(result.if);
|
||||
}
|
||||
if (isPlainObject(result.then)) {
|
||||
result.then = coerceSchemaNumericFields(result.then);
|
||||
}
|
||||
if (isPlainObject(result.else)) {
|
||||
result.else = coerceSchemaNumericFields(result.else);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply schema coercion to all tools in a request body.
|
||||
* Handles both OpenAI format (function.parameters) and Claude format (input_schema).
|
||||
*/
|
||||
export function coerceToolSchemas(tools: any[]): any[] {
|
||||
export function sanitizeToolDescription(tool: unknown): unknown {
|
||||
if (!isPlainObject(tool)) return tool;
|
||||
|
||||
const result: JsonRecord = { ...tool };
|
||||
|
||||
if (isPlainObject(result.function) && "description" in result.function) {
|
||||
const description = sanitizeDescriptionValue(result.function.description);
|
||||
if (description !== undefined) {
|
||||
result.function = { ...result.function, description };
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPlainObject(result.function) && "description" in result) {
|
||||
const description = sanitizeDescriptionValue(result.description);
|
||||
if (description !== undefined) {
|
||||
result.description = description;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(result.functionDeclarations)) {
|
||||
result.functionDeclarations = result.functionDeclarations.map((declaration) => {
|
||||
if (!isPlainObject(declaration) || !("description" in declaration)) return declaration;
|
||||
const description = sanitizeDescriptionValue(declaration.description);
|
||||
return description === undefined ? declaration : { ...declaration, description };
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function coerceToolSchemas(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
|
||||
return tools.map((tool) => {
|
||||
if (!tool || typeof tool !== "object") return tool;
|
||||
if (!isPlainObject(tool)) return tool;
|
||||
|
||||
const result = { ...tool };
|
||||
const result: JsonRecord = { ...tool };
|
||||
|
||||
// OpenAI format: tool.function.parameters
|
||||
if (result.function?.parameters) {
|
||||
if (isPlainObject(result.function) && "parameters" in result.function) {
|
||||
result.function = {
|
||||
...result.function,
|
||||
parameters: coerceSchemaNumericFields(result.function.parameters),
|
||||
};
|
||||
}
|
||||
|
||||
// Claude format: tool.input_schema
|
||||
if (result.input_schema) {
|
||||
if (result.input_schema !== undefined) {
|
||||
result.input_schema = coerceSchemaNumericFields(result.input_schema);
|
||||
}
|
||||
|
||||
// Direct parameters (some formats)
|
||||
if (result.parameters && !result.function) {
|
||||
if ("parameters" in result && !isPlainObject(result.function)) {
|
||||
result.parameters = coerceSchemaNumericFields(result.parameters);
|
||||
}
|
||||
|
||||
if (Array.isArray(result.functionDeclarations)) {
|
||||
result.functionDeclarations = result.functionDeclarations.map((declaration) => {
|
||||
if (!isPlainObject(declaration) || !("parameters" in declaration)) return declaration;
|
||||
return {
|
||||
...declaration,
|
||||
parameters: coerceSchemaNumericFields(declaration.parameters),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure tool.description is always a string.
|
||||
* Some clients send null, undefined, or numeric descriptions.
|
||||
*/
|
||||
export function sanitizeToolDescription(tool: any): any {
|
||||
if (!tool || typeof tool !== "object") return tool;
|
||||
|
||||
const result = { ...tool };
|
||||
|
||||
// OpenAI format: tool.function.description
|
||||
if (result.function && result.function.description !== undefined) {
|
||||
if (result.function.description === null) {
|
||||
result.function = { ...result.function, description: "" };
|
||||
} else if (typeof result.function.description !== "string") {
|
||||
result.function = { ...result.function, description: String(result.function.description) };
|
||||
}
|
||||
}
|
||||
|
||||
// Claude format: tool.description (direct)
|
||||
if ("description" in result && !result.function) {
|
||||
if (result.description === null) {
|
||||
result.description = "";
|
||||
} else if (typeof result.description !== "string") {
|
||||
result.description = String(result.description);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply description sanitization to all tools in a request body.
|
||||
*/
|
||||
export function sanitizeToolDescriptions(tools: any[]): any[] {
|
||||
export function sanitizeToolDescriptions(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
return tools.map(sanitizeToolDescription);
|
||||
return tools.map((tool) => sanitizeToolDescription(tool));
|
||||
}
|
||||
|
||||
export function injectEmptyReasoningContentForToolCalls(
|
||||
messages: unknown,
|
||||
provider: unknown
|
||||
): unknown {
|
||||
if (!Array.isArray(messages) || String(provider || "").toLowerCase() !== "deepseek") {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages.map((message) => {
|
||||
if (!isPlainObject(message)) return message;
|
||||
if (
|
||||
message.role !== "assistant" ||
|
||||
!Array.isArray(message.tool_calls) ||
|
||||
message.tool_calls.length === 0 ||
|
||||
message.reasoning_content !== undefined
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return { ...message, reasoning_content: "" };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import { ensureToolCallIds, fixMissingToolResponses } from "./helpers/toolCallHe
|
||||
import { coerceToolSchemas, sanitizeToolDescriptions } from "./helpers/schemaCoercion.ts";
|
||||
import { prepareClaudeRequest } from "./helpers/claudeHelper.ts";
|
||||
import { filterToOpenAIFormat } from "./helpers/openaiHelper.ts";
|
||||
import {
|
||||
coerceToolSchemas,
|
||||
injectEmptyReasoningContentForToolCalls,
|
||||
sanitizeToolDescriptions,
|
||||
} from "./helpers/schemaCoercion.ts";
|
||||
import { getRequestTranslator, getResponseTranslator } from "./registry.ts";
|
||||
import { bootstrapTranslatorRegistry } from "./bootstrap.ts";
|
||||
import { normalizeThinkingConfig } from "../services/provider.ts";
|
||||
@@ -172,6 +177,15 @@ export function translateRequest(
|
||||
);
|
||||
}
|
||||
|
||||
if (result.tools !== undefined) {
|
||||
result.tools = coerceToolSchemas(result.tools);
|
||||
result.tools = sanitizeToolDescriptions(result.tools);
|
||||
}
|
||||
|
||||
if (targetFormat === FORMATS.OPENAI && result.messages && Array.isArray(result.messages)) {
|
||||
result.messages = injectEmptyReasoningContentForToolCalls(result.messages, provider);
|
||||
}
|
||||
|
||||
// Ensure unique tool_call ids on final payload (translators may have introduced duplicates)
|
||||
ensureToolCallIds(result, { use9CharId });
|
||||
fixMissingToolResponses(result);
|
||||
|
||||
317
package-lock.json
generated
317
package-lock.json
generated
@@ -60,6 +60,7 @@
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"c8": "^11.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
@@ -511,6 +512,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@braintree/sanitize-url": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
|
||||
@@ -2160,6 +2171,16 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -6007,6 +6028,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/js-cookie": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
|
||||
@@ -7659,6 +7687,40 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/c8": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz",
|
||||
"integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.1",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"find-up": "^5.0.0",
|
||||
"foreground-child": "^3.1.1",
|
||||
"istanbul-lib-coverage": "^3.2.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.1.6",
|
||||
"test-exclude": "^8.0.0",
|
||||
"v8-to-istanbul": "^9.0.0",
|
||||
"yargs": "^17.7.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"c8": "bin/c8.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monocart-coverage-reports": "^2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"monocart-coverage-reports": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
@@ -10522,6 +10584,23 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
@@ -10803,6 +10882,24 @@
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.2.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -10816,6 +10913,45 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
@@ -11282,6 +11418,13 @@
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -12210,6 +12353,45 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/iterator.prototype": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
|
||||
@@ -13059,6 +13241,35 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-extensions": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz",
|
||||
@@ -14461,6 +14672,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mixin-deep": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
|
||||
@@ -15278,6 +15499,33 @@
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
@@ -18144,6 +18392,60 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
|
||||
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^13.0.6",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
@@ -18902,6 +19204,21 @@
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.12",
|
||||
"@types/istanbul-lib-coverage": "^2.0.1",
|
||||
"convert-source-map": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v8n": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/v8n/-/v8n-1.5.1.tgz",
|
||||
|
||||
@@ -72,7 +72,10 @@
|
||||
"test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs",
|
||||
"test:vitest": "vitest run open-sse/mcp-server/__tests__/*.test.ts open-sse/services/autoCombo/__tests__/*.test.ts",
|
||||
"test:ecosystem": "node scripts/run-ecosystem-tests.mjs",
|
||||
"test:coverage": "npx c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:coverage": "c8 --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 55 --lines 55 --functions 55 --branches 60 node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:coverage:legacy": "c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"coverage:report": "c8 report --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov",
|
||||
"coverage:report:legacy": "c8 report --exclude=open-sse --reporter=text --reporter=text-summary",
|
||||
"test:all": "npm run test:unit && npm run test:vitest && npm run test:ecosystem && npm run test:e2e",
|
||||
"check": "npm run lint && npm run test",
|
||||
"prepublishOnly": "npm run build:cli",
|
||||
@@ -124,6 +127,7 @@
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"c8": "^11.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
|
||||
@@ -403,6 +403,10 @@ interface ConnectionRowProps {
|
||||
proxyHost?: string;
|
||||
onRefreshToken?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
onApplyCodexAuthLocal?: () => void;
|
||||
isApplyingCodexAuthLocal?: boolean;
|
||||
onExportCodexAuthFile?: () => void;
|
||||
isExportingCodexAuthFile?: boolean;
|
||||
}
|
||||
|
||||
interface AddApiKeyModalProps {
|
||||
@@ -821,6 +825,8 @@ export default function ProviderDetailPage() {
|
||||
modelCompatOverrides: Array<CompatModelRow & { id: string }>;
|
||||
}>({ customModels: [], modelCompatOverrides: [] });
|
||||
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
|
||||
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
|
||||
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(null);
|
||||
|
||||
const providerInfo = providerNode
|
||||
? {
|
||||
@@ -1248,6 +1254,39 @@ export default function ProviderDetailPage() {
|
||||
|
||||
// T12: Manual token refresh
|
||||
const [refreshingId, setRefreshingId] = useState<string | null>(null);
|
||||
|
||||
const parseApiErrorMessage = async (res: Response, fallback: string) => {
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (typeof data?.error === "string" && data.error.trim()) {
|
||||
return data.error;
|
||||
}
|
||||
if (data?.error?.message) {
|
||||
return data.error.message;
|
||||
}
|
||||
}
|
||||
|
||||
const text = await res.text().catch(() => "");
|
||||
return text.trim() || fallback;
|
||||
};
|
||||
|
||||
const getAttachmentFilename = (res: Response, fallback: string) => {
|
||||
const disposition = res.headers.get("content-disposition") || "";
|
||||
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
}
|
||||
|
||||
const plainMatch = disposition.match(/filename="([^"]+)"/i);
|
||||
if (plainMatch?.[1]) {
|
||||
return plainMatch[1];
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const handleRefreshToken = async (connectionId: string) => {
|
||||
if (refreshingId) return;
|
||||
setRefreshingId(connectionId);
|
||||
@@ -1268,6 +1307,82 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyCodexAuthLocal = async (connectionId: string) => {
|
||||
if (applyingCodexAuthId) return;
|
||||
setApplyingCodexAuthId(connectionId);
|
||||
|
||||
const defaultSuccess =
|
||||
typeof t.has === "function" && t.has("codexAuthAppliedLocal")
|
||||
? t("codexAuthAppliedLocal")
|
||||
: "Codex auth.json applied locally";
|
||||
const defaultError =
|
||||
typeof t.has === "function" && t.has("codexAuthApplyFailed")
|
||||
? t("codexAuthApplyFailed")
|
||||
: "Failed to apply Codex auth.json locally";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}/codex-auth/apply-local`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
notify.error(await parseApiErrorMessage(res, defaultError));
|
||||
return;
|
||||
}
|
||||
|
||||
notify.success(defaultSuccess);
|
||||
} catch (error) {
|
||||
console.error("Error applying Codex auth locally:", error);
|
||||
notify.error(defaultError);
|
||||
} finally {
|
||||
setApplyingCodexAuthId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCodexAuthFile = async (connectionId: string) => {
|
||||
if (exportingCodexAuthId) return;
|
||||
setExportingCodexAuthId(connectionId);
|
||||
|
||||
const defaultSuccess =
|
||||
typeof t.has === "function" && t.has("codexAuthExported")
|
||||
? t("codexAuthExported")
|
||||
: "Codex auth.json exported";
|
||||
const defaultError =
|
||||
typeof t.has === "function" && t.has("codexAuthExportFailed")
|
||||
? t("codexAuthExportFailed")
|
||||
: "Failed to export Codex auth.json";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}/codex-auth/export`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
notify.error(await parseApiErrorMessage(res, defaultError));
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const filename = getAttachmentFilename(res, "codex-auth.json");
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000);
|
||||
|
||||
notify.success(defaultSuccess);
|
||||
} catch (error) {
|
||||
console.error("Error exporting Codex auth file:", error);
|
||||
notify.error(defaultError);
|
||||
} finally {
|
||||
setExportingCodexAuthId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwapPriority = async (conn1, conn2) => {
|
||||
if (!conn1 || !conn2) return;
|
||||
try {
|
||||
@@ -2103,6 +2218,18 @@ export default function ProviderDetailPage() {
|
||||
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
|
||||
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
providerId === "codex"
|
||||
? () => handleApplyCodexAuthLocal(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
|
||||
onExportCodexAuthFile={
|
||||
providerId === "codex"
|
||||
? () => handleExportCodexAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
|
||||
onProxy={() =>
|
||||
setProxyTarget({
|
||||
level: "key",
|
||||
@@ -2194,6 +2321,18 @@ export default function ProviderDetailPage() {
|
||||
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
|
||||
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
providerId === "codex"
|
||||
? () => handleApplyCodexAuthLocal(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
|
||||
onExportCodexAuthFile={
|
||||
providerId === "codex"
|
||||
? () => handleExportCodexAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
|
||||
onProxy={() =>
|
||||
setProxyTarget({
|
||||
level: "key",
|
||||
@@ -3776,11 +3915,23 @@ function ConnectionRow({
|
||||
proxyHost,
|
||||
onRefreshToken,
|
||||
isRefreshing,
|
||||
onApplyCodexAuthLocal,
|
||||
isApplyingCodexAuthLocal,
|
||||
onExportCodexAuthFile,
|
||||
isExportingCodexAuthFile,
|
||||
}: ConnectionRowProps) {
|
||||
const t = useTranslations("providers");
|
||||
const displayName = isOAuth
|
||||
? connection.name || connection.email || connection.displayName || t("oauthAccount")
|
||||
: connection.name;
|
||||
const applyCodexAuthLabel =
|
||||
typeof t.has === "function" && t.has("applyCodexAuthLocal")
|
||||
? t("applyCodexAuthLocal")
|
||||
: "Apply auth";
|
||||
const exportCodexAuthLabel =
|
||||
typeof t.has === "function" && t.has("exportCodexAuthFile")
|
||||
? t("exportCodexAuthFile")
|
||||
: "Export auth";
|
||||
|
||||
// Use useState + useEffect for impure Date.now() to avoid calling during render
|
||||
const [isCooldown, setIsCooldown] = useState(false);
|
||||
@@ -4014,6 +4165,34 @@ function ConnectionRow({
|
||||
Token
|
||||
</Button>
|
||||
)}
|
||||
{isCodex && onApplyCodexAuthLocal && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="download_done"
|
||||
loading={isApplyingCodexAuthLocal}
|
||||
disabled={isApplyingCodexAuthLocal}
|
||||
onClick={onApplyCodexAuthLocal}
|
||||
className="!h-7 !px-2 text-xs text-emerald-500 hover:text-emerald-400"
|
||||
title={applyCodexAuthLabel}
|
||||
>
|
||||
{applyCodexAuthLabel}
|
||||
</Button>
|
||||
)}
|
||||
{isCodex && onExportCodexAuthFile && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="download"
|
||||
loading={isExportingCodexAuthFile}
|
||||
disabled={isExportingCodexAuthFile}
|
||||
onClick={onExportCodexAuthFile}
|
||||
className="!h-7 !px-2 text-xs text-sky-500 hover:text-sky-400"
|
||||
title={exportCodexAuthLabel}
|
||||
>
|
||||
{exportCodexAuthLabel}
|
||||
</Button>
|
||||
)}
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={connection.isActive ?? true}
|
||||
@@ -4090,6 +4269,10 @@ ConnectionRow.propTypes = {
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onReauth: PropTypes.func,
|
||||
onApplyCodexAuthLocal: PropTypes.func,
|
||||
isApplyingCodexAuthLocal: PropTypes.bool,
|
||||
onExportCodexAuthFile: PropTypes.func,
|
||||
isExportingCodexAuthFile: PropTypes.bool,
|
||||
};
|
||||
|
||||
function AddApiKeyModal({
|
||||
|
||||
@@ -18,6 +18,11 @@ export default function SystemStorageTab() {
|
||||
const [importStatus, setImportStatus] = useState({ type: "", message: "" });
|
||||
const [confirmImport, setConfirmImport] = useState(false);
|
||||
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
|
||||
const [maxCallLogs, setMaxCallLogs] = useState(10000);
|
||||
const [maxCallLogsDraft, setMaxCallLogsDraft] = useState("10000");
|
||||
const [settingsLoading, setSettingsLoading] = useState(true);
|
||||
const [maxCallLogsSaving, setMaxCallLogsSaving] = useState(false);
|
||||
const [maxCallLogsStatus, setMaxCallLogsStatus] = useState({ type: "", message: "" });
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("settings");
|
||||
@@ -54,6 +59,27 @@ export default function SystemStorageTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
setSettingsLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const value =
|
||||
typeof data.maxCallLogs === "number" &&
|
||||
Number.isInteger(data.maxCallLogs) &&
|
||||
data.maxCallLogs > 0
|
||||
? data.maxCallLogs
|
||||
: 10000;
|
||||
setMaxCallLogs(value);
|
||||
setMaxCallLogsDraft(String(value));
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch settings:", err);
|
||||
} finally {
|
||||
setSettingsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualBackup = async () => {
|
||||
setManualBackupLoading(true);
|
||||
setManualBackupStatus({ type: "", message: "" });
|
||||
@@ -119,8 +145,47 @@ export default function SystemStorageTab() {
|
||||
|
||||
useEffect(() => {
|
||||
loadStorageHealth();
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const handleSaveMaxCallLogs = async () => {
|
||||
const parsed = Number.parseInt(maxCallLogsDraft, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
setMaxCallLogsStatus({
|
||||
type: "error",
|
||||
message: "Enter a positive integer for the call log limit.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setMaxCallLogsSaving(true);
|
||||
setMaxCallLogsStatus({ type: "", message: "" });
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ maxCallLogs: parsed }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to save call log limit");
|
||||
}
|
||||
setMaxCallLogs(parsed);
|
||||
setMaxCallLogsDraft(String(parsed));
|
||||
setMaxCallLogsStatus({
|
||||
type: "success",
|
||||
message: "Call log retention limit saved.",
|
||||
});
|
||||
} catch (err) {
|
||||
setMaxCallLogsStatus({
|
||||
type: "error",
|
||||
message: (err as Error).message || "Failed to save call log limit",
|
||||
});
|
||||
} finally {
|
||||
setMaxCallLogsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
@@ -276,6 +341,56 @@ export default function SystemStorageTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">Call log retention limit</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Keep only the most recent call log entries in SQLite. Older entries are pruned
|
||||
automatically after each new request log is saved.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="default" size="sm">
|
||||
{maxCallLogs.toLocaleString()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={maxCallLogsDraft}
|
||||
onChange={(e) => setMaxCallLogsDraft(e.target.value)}
|
||||
disabled={settingsLoading || maxCallLogsSaving}
|
||||
className="w-40 rounded-lg border border-border bg-bg-secondary px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
aria-label="Call log retention limit"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveMaxCallLogs}
|
||||
loading={maxCallLogsSaving}
|
||||
disabled={settingsLoading}
|
||||
>
|
||||
Save limit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{maxCallLogsStatus.message && (
|
||||
<div
|
||||
className={`mt-3 rounded-lg border px-3 py-2 text-sm ${
|
||||
maxCallLogsStatus.type === "success"
|
||||
? "border-green-500/20 bg-green-500/10 text-green-500"
|
||||
: "border-red-500/20 bg-red-500/10 text-red-500"
|
||||
}`}
|
||||
role="alert"
|
||||
>
|
||||
{maxCallLogsStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export / Import */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<Button variant="outline" size="sm" onClick={handleExport} loading={exportLoading}>
|
||||
|
||||
41
src/app/api/providers/[id]/codex-auth/apply-local/route.ts
Normal file
41
src/app/api/providers/[id]/codex-auth/apply-local/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
|
||||
import { CodexAuthFileError, writeCodexAuthFileToLocalCli } from "@/lib/oauth/utils/codexAuthFile";
|
||||
|
||||
function toErrorResponse(error: unknown) {
|
||||
if (error instanceof CodexAuthFileError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
},
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : "Failed to apply Codex auth file";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
|
||||
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const writeGuard = ensureCliConfigWriteAllowed();
|
||||
if (writeGuard) {
|
||||
return NextResponse.json({ error: writeGuard, code: "writes_disabled" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const result = await writeCodexAuthFileToLocalCli(id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connectionId: id,
|
||||
connectionLabel: result.connectionLabel,
|
||||
authPath: result.authPath,
|
||||
writtenAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Codex Auth Apply] Failed:", error);
|
||||
return toErrorResponse(error);
|
||||
}
|
||||
}
|
||||
37
src/app/api/providers/[id]/codex-auth/export/route.ts
Normal file
37
src/app/api/providers/[id]/codex-auth/export/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildCodexAuthFile, CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile";
|
||||
|
||||
function toErrorResponse(error: unknown) {
|
||||
if (error instanceof CodexAuthFileError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
},
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : "Failed to export Codex auth file";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
|
||||
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const built = await buildCodexAuthFile(id);
|
||||
|
||||
return new Response(built.content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${built.fileName}"`,
|
||||
"Cache-Control": "no-store, max-age=0",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Codex Auth Export] Failed:", error);
|
||||
return toErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,11 @@ export async function PATCH(request) {
|
||||
setCliCompatProviders(body.cliCompatProviders || []);
|
||||
}
|
||||
|
||||
if ("maxCallLogs" in body) {
|
||||
const { invalidateCallLogsMaxCache } = await import("@/lib/usage/callLogs");
|
||||
invalidateCallLogsMaxCache();
|
||||
}
|
||||
|
||||
const { password, ...safeSettings } = settings;
|
||||
return NextResponse.json(safeSettings);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1635,6 +1635,12 @@
|
||||
"compatibleProdPlaceholder": "{type} Compatible (Prod)",
|
||||
"tokenRefreshed": "Token refreshed successfully",
|
||||
"tokenRefreshFailed": "Token refresh failed",
|
||||
"applyCodexAuthLocal": "Apply auth",
|
||||
"exportCodexAuthFile": "Export auth",
|
||||
"codexAuthAppliedLocal": "Codex auth.json applied locally",
|
||||
"codexAuthApplyFailed": "Failed to apply Codex auth.json locally",
|
||||
"codexAuthExported": "Codex auth.json exported",
|
||||
"codexAuthExportFailed": "Failed to export Codex auth.json",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"chatPathLabel": "Chat Endpoint Path",
|
||||
"chatPathPlaceholder": "/chat/completions",
|
||||
|
||||
@@ -1579,6 +1579,12 @@
|
||||
"compatProtocolClaude": "Anthropic Messages",
|
||||
"tokenRefreshed": "Token refreshed successfully",
|
||||
"tokenRefreshFailed": "Token refresh failed",
|
||||
"applyCodexAuthLocal": "Aplicar auth",
|
||||
"exportCodexAuthFile": "Exportar auth",
|
||||
"codexAuthAppliedLocal": "auth.json do Codex aplicado localmente",
|
||||
"codexAuthApplyFailed": "Falha ao aplicar o auth.json do Codex localmente",
|
||||
"codexAuthExported": "auth.json do Codex exportado",
|
||||
"codexAuthExportFailed": "Falha ao exportar o auth.json do Codex",
|
||||
"compatBadgeUpstreamHeaders": "Headers",
|
||||
"compatUpstreamAddRow": "Add header",
|
||||
"compatUpstreamHeaderName": "Header name",
|
||||
|
||||
298
src/lib/oauth/utils/codexAuthFile.ts
Normal file
298
src/lib/oauth/utils/codexAuthFile.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { getProviderConnectionById } from "@/lib/localDb";
|
||||
import { createBackup } from "@/shared/services/backupService";
|
||||
import { getCliConfigPaths } from "@/shared/services/cliRuntime";
|
||||
import {
|
||||
TOKEN_EXPIRY_BUFFER_MS,
|
||||
getAccessToken,
|
||||
updateProviderCredentials,
|
||||
} from "@/sse/services/tokenRefresh";
|
||||
import { isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface CodexConnectionLike {
|
||||
id?: string;
|
||||
provider?: string;
|
||||
authType?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
accessToken?: string | null;
|
||||
refreshToken?: string | null;
|
||||
idToken?: string | null;
|
||||
expiresAt?: string | null;
|
||||
expiresIn?: number | null;
|
||||
providerSpecificData?: JsonRecord | null;
|
||||
}
|
||||
|
||||
export interface CodexAuthFilePayload {
|
||||
auth_mode: "chatgpt";
|
||||
OPENAI_API_KEY: null;
|
||||
tokens: {
|
||||
id_token: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
account_id: string;
|
||||
};
|
||||
last_refresh: string;
|
||||
}
|
||||
|
||||
export interface BuiltCodexAuthFile {
|
||||
connectionId: string;
|
||||
connectionLabel: string;
|
||||
fileName: string;
|
||||
payload: CodexAuthFilePayload;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class CodexAuthFileError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
|
||||
constructor(message: string, status = 400, code = "invalid_request") {
|
||||
super(message);
|
||||
this.name = "CodexAuthFileError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const CODEX_REFRESH_BUFFER_MS = Math.max(TOKEN_EXPIRY_BUFFER_MS, 5 * 60 * 1000);
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(jwt: string): JsonRecord | null {
|
||||
try {
|
||||
const parts = jwt.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = Buffer.from(parts[1], "base64url").toString("utf8");
|
||||
return toRecord(JSON.parse(payload));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractCodexAccountId(idToken: string, providerSpecificData: unknown): string | null {
|
||||
const payload = decodeJwtPayload(idToken);
|
||||
const authInfo = payload ? toRecord(payload["https://api.openai.com/auth"]) : {};
|
||||
|
||||
return (
|
||||
toNonEmptyString(authInfo.chatgpt_account_id) ||
|
||||
toNonEmptyString(authInfo.account_id) ||
|
||||
toNonEmptyString(toRecord(providerSpecificData).workspaceId)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRefreshCodexConnection(connection: CodexConnectionLike): boolean {
|
||||
if (!toNonEmptyString(connection.accessToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const expiresAt = toNonEmptyString(connection.expiresAt);
|
||||
if (!expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAtMs = new Date(expiresAt).getTime();
|
||||
if (Number.isNaN(expiresAtMs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return expiresAtMs - Date.now() <= CODEX_REFRESH_BUFFER_MS;
|
||||
}
|
||||
|
||||
function getConnectionLabel(connection: CodexConnectionLike): string {
|
||||
return (
|
||||
toNonEmptyString(connection.name) ||
|
||||
toNonEmptyString(connection.email) ||
|
||||
toNonEmptyString(connection.displayName) ||
|
||||
toNonEmptyString(connection.id) ||
|
||||
"codex-account"
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeFileNamePart(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return normalized || "account";
|
||||
}
|
||||
|
||||
function buildCodexAuthPayload(connection: CodexConnectionLike): CodexAuthFilePayload {
|
||||
const idToken = toNonEmptyString(connection.idToken);
|
||||
const accessToken = toNonEmptyString(connection.accessToken);
|
||||
const refreshToken = toNonEmptyString(connection.refreshToken);
|
||||
|
||||
if (!idToken) {
|
||||
throw new CodexAuthFileError(
|
||||
"Codex connection is missing id_token. Re-authenticate this account before exporting.",
|
||||
409,
|
||||
"reauth_required"
|
||||
);
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
throw new CodexAuthFileError(
|
||||
"Codex connection is missing access_token. Refresh or re-authenticate this account first.",
|
||||
409,
|
||||
"access_token_missing"
|
||||
);
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new CodexAuthFileError(
|
||||
"Codex connection is missing refresh_token. Re-authenticate this account before exporting.",
|
||||
409,
|
||||
"reauth_required"
|
||||
);
|
||||
}
|
||||
|
||||
const accountId = extractCodexAccountId(idToken, connection.providerSpecificData);
|
||||
if (!accountId) {
|
||||
throw new CodexAuthFileError(
|
||||
"Unable to derive Codex account_id from the stored session. Re-authenticate this account.",
|
||||
409,
|
||||
"account_id_missing"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: idToken,
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
account_id: accountId,
|
||||
},
|
||||
last_refresh: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveFreshCodexConnection(connectionId: string): Promise<CodexConnectionLike> {
|
||||
const connection = (await getProviderConnectionById(connectionId)) as CodexConnectionLike | null;
|
||||
if (!connection) {
|
||||
throw new CodexAuthFileError("Connection not found", 404, "not_found");
|
||||
}
|
||||
|
||||
if (connection.provider !== "codex") {
|
||||
throw new CodexAuthFileError("Only Codex provider connections can export Codex auth files");
|
||||
}
|
||||
|
||||
if (connection.authType !== "oauth") {
|
||||
throw new CodexAuthFileError("Only OAuth Codex connections support auth.json export");
|
||||
}
|
||||
|
||||
if (!shouldRefreshCodexConnection(connection)) {
|
||||
return connection;
|
||||
}
|
||||
|
||||
const refreshToken = toNonEmptyString(connection.refreshToken);
|
||||
if (!refreshToken) {
|
||||
throw new CodexAuthFileError(
|
||||
"Codex connection requires refresh but no refresh_token is available. Re-authenticate first.",
|
||||
409,
|
||||
"reauth_required"
|
||||
);
|
||||
}
|
||||
|
||||
const refreshed = await getAccessToken("codex", {
|
||||
connectionId,
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken,
|
||||
expiresAt: connection.expiresAt,
|
||||
expiresIn: connection.expiresIn,
|
||||
idToken: connection.idToken,
|
||||
providerSpecificData: connection.providerSpecificData,
|
||||
});
|
||||
|
||||
if (isUnrecoverableRefreshError(refreshed)) {
|
||||
throw new CodexAuthFileError(
|
||||
"Codex refresh token is no longer valid. Re-authenticate this account before exporting.",
|
||||
409,
|
||||
"reauth_required"
|
||||
);
|
||||
}
|
||||
|
||||
if (!refreshed?.accessToken) {
|
||||
throw new CodexAuthFileError(
|
||||
"Failed to refresh the Codex session before exporting the auth file. Re-authenticate this account if the session is stale.",
|
||||
502,
|
||||
"refresh_failed"
|
||||
);
|
||||
}
|
||||
|
||||
await updateProviderCredentials(connectionId, refreshed);
|
||||
|
||||
return {
|
||||
...connection,
|
||||
accessToken: refreshed.accessToken,
|
||||
refreshToken: toNonEmptyString(refreshed.refreshToken) || refreshToken,
|
||||
expiresIn:
|
||||
typeof refreshed.expiresIn === "number" ? refreshed.expiresIn : connection.expiresIn || null,
|
||||
expiresAt:
|
||||
typeof refreshed.expiresIn === "number"
|
||||
? new Date(Date.now() + refreshed.expiresIn * 1000).toISOString()
|
||||
: connection.expiresAt || null,
|
||||
providerSpecificData: refreshed.providerSpecificData
|
||||
? {
|
||||
...toRecord(connection.providerSpecificData),
|
||||
...toRecord(refreshed.providerSpecificData),
|
||||
}
|
||||
: connection.providerSpecificData,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildCodexAuthFile(connectionId: string): Promise<BuiltCodexAuthFile> {
|
||||
const connection = await resolveFreshCodexConnection(connectionId);
|
||||
const payload = buildCodexAuthPayload(connection);
|
||||
const connectionLabel = getConnectionLabel(connection);
|
||||
const fileName = `codex-auth-${sanitizeFileNamePart(connectionLabel)}.json`;
|
||||
const content = JSON.stringify(payload, null, 2) + "\n";
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
connectionLabel,
|
||||
fileName,
|
||||
payload,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeCodexAuthFileToLocalCli(connectionId: string) {
|
||||
const built = await buildCodexAuthFile(connectionId);
|
||||
const paths = getCliConfigPaths("codex");
|
||||
const authPath = paths?.auth;
|
||||
|
||||
if (!authPath) {
|
||||
throw new CodexAuthFileError("Codex auth path could not be resolved", 500, "path_unavailable");
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(authPath), { recursive: true });
|
||||
await createBackup("codex", authPath);
|
||||
await fs.writeFile(authPath, built.content, { encoding: "utf8", mode: 0o600 });
|
||||
|
||||
try {
|
||||
await fs.chmod(authPath, 0o600);
|
||||
} catch {
|
||||
// Best effort on platforms that ignore chmod semantics.
|
||||
}
|
||||
|
||||
return {
|
||||
...built,
|
||||
authPath,
|
||||
};
|
||||
}
|
||||
@@ -50,18 +50,7 @@ function hasTruncatedFlag(value: unknown): boolean {
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_CALL_LOGS = 10000;
|
||||
|
||||
async function getMaxCallLogs(): Promise<number> {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const setting = settings.maxCallLogs;
|
||||
if (setting) {
|
||||
const num = parseInt(String(setting), 10);
|
||||
if (!isNaN(num) && num > 0) return num;
|
||||
}
|
||||
} catch {}
|
||||
return DEFAULT_MAX_CALL_LOGS;
|
||||
}
|
||||
const CALL_LOGS_MAX_CACHE_TTL_MS = 30_000;
|
||||
|
||||
const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || "7", 10);
|
||||
const CALL_LOG_PAYLOAD_MODE = (() => {
|
||||
@@ -71,6 +60,55 @@ const CALL_LOG_PAYLOAD_MODE = (() => {
|
||||
const shouldLogPayloadInDb = CALL_LOG_PAYLOAD_MODE !== "none";
|
||||
const shouldLogPayloadOnDisk = CALL_LOG_PAYLOAD_MODE === "full";
|
||||
|
||||
let callLogsMaxCache = {
|
||||
value: resolveCallLogsMaxValue(process.env.CALL_LOGS_MAX) ?? DEFAULT_MAX_CALL_LOGS,
|
||||
expiresAt: 0,
|
||||
};
|
||||
|
||||
function resolveCallLogsMaxValue(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getMaxCallLogs(): Promise<number> {
|
||||
const now = Date.now();
|
||||
if (callLogsMaxCache.expiresAt > now) {
|
||||
return callLogsMaxCache.value;
|
||||
}
|
||||
|
||||
let value = resolveCallLogsMaxValue(process.env.CALL_LOGS_MAX) ?? DEFAULT_MAX_CALL_LOGS;
|
||||
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/localDb");
|
||||
const settings = await getSettings();
|
||||
const configured =
|
||||
resolveCallLogsMaxValue(settings.maxCallLogs) ??
|
||||
resolveCallLogsMaxValue(settings.MAX_CALL_LOGS);
|
||||
if (configured !== null) {
|
||||
value = configured;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to env/default cap when settings are unavailable.
|
||||
}
|
||||
|
||||
callLogsMaxCache = {
|
||||
value,
|
||||
expiresAt: now + CALL_LOGS_MAX_CACHE_TTL_MS,
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
export function invalidateCallLogsMaxCache(): void {
|
||||
callLogsMaxCache = {
|
||||
value: resolveCallLogsMaxValue(process.env.CALL_LOGS_MAX) ?? DEFAULT_MAX_CALL_LOGS,
|
||||
expiresAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fields that should always be redacted from logged payloads */
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
"api_key",
|
||||
|
||||
@@ -106,6 +106,12 @@ export const CLI_TOOLS = {
|
||||
description: "Windsurf AI-first IDE by Codeium",
|
||||
docsUrl: "https://windsurf.com/",
|
||||
configType: "guide",
|
||||
notes: [
|
||||
{
|
||||
type: "warning",
|
||||
text: "Official Windsurf docs currently describe BYOK for select Claude models plus enterprise URL/token settings, not a generic custom OpenAI-compatible provider.",
|
||||
},
|
||||
],
|
||||
guideSteps: [
|
||||
{
|
||||
step: 1,
|
||||
|
||||
@@ -59,6 +59,13 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
state: ".cursor/agent-cli-state.json",
|
||||
},
|
||||
},
|
||||
windsurf: {
|
||||
defaultCommand: null,
|
||||
envBinKey: "CLI_WINDSURF_BIN",
|
||||
requiresBinary: false,
|
||||
healthcheckTimeoutMs: 4000,
|
||||
paths: {},
|
||||
},
|
||||
cline: {
|
||||
defaultCommand: "cline",
|
||||
envBinKey: "CLI_CLINE_BIN",
|
||||
|
||||
@@ -149,6 +149,7 @@ export const updateSettingsSchema = z.object({
|
||||
instanceName: z.string().max(100).optional(),
|
||||
corsOrigins: z.string().max(500).optional(),
|
||||
logRetentionDays: z.number().int().min(1).max(365).optional(),
|
||||
maxCallLogs: z.number().int().min(1).max(1_000_000).optional(),
|
||||
cloudUrl: z.string().max(500).optional(),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
setupComplete: z.boolean().optional(),
|
||||
|
||||
@@ -18,6 +18,7 @@ export const updateSettingsSchema = z.object({
|
||||
instanceName: z.string().max(100).optional(),
|
||||
corsOrigins: z.string().max(500).optional(),
|
||||
logRetentionDays: z.number().int().min(1).max(365).optional(),
|
||||
maxCallLogs: z.number().int().min(1).max(1_000_000).optional(),
|
||||
cloudUrl: z.string().max(500).optional(),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
setupComplete: z.boolean().optional(),
|
||||
|
||||
162
tests/unit/api-auth.test.mjs
Normal file
162
tests/unit/api-auth.test.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SignJWT } from "jose";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-api-auth-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const apiAuth = await import("../../src/shared/utils/apiAuth.ts");
|
||||
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.JWT_SECRET;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
}
|
||||
|
||||
function makeCookieRequest(token) {
|
||||
return {
|
||||
cookies: {
|
||||
get(name) {
|
||||
return name === "auth_token" && token ? { value: token } : undefined;
|
||||
},
|
||||
},
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
|
||||
if (ORIGINAL_JWT_SECRET === undefined) {
|
||||
delete process.env.JWT_SECRET;
|
||||
} else {
|
||||
process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
}
|
||||
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
} else {
|
||||
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
}
|
||||
});
|
||||
|
||||
test("isPublicRoute recognizes allowed API prefixes", () => {
|
||||
assert.equal(apiAuth.isPublicRoute("/api/auth/login"), true);
|
||||
assert.equal(apiAuth.isPublicRoute("/api/v1/chat/completions"), true);
|
||||
assert.equal(apiAuth.isPublicRoute("/api/settings"), false);
|
||||
});
|
||||
|
||||
test("verifyAuth accepts a valid JWT session cookie", async () => {
|
||||
process.env.JWT_SECRET = "jwt-secret-for-tests";
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const token = await new SignJWT({ authenticated: true })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
|
||||
const result = await apiAuth.verifyAuth(makeCookieRequest(token));
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("verifyAuth falls back to bearer API key validation after a bad JWT", async () => {
|
||||
process.env.JWT_SECRET = "jwt-secret-for-tests";
|
||||
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
|
||||
const request = {
|
||||
cookies: {
|
||||
get() {
|
||||
return { value: "definitely-not-a-valid-jwt" };
|
||||
},
|
||||
},
|
||||
headers: new Headers({ authorization: `Bearer ${key.key}` }),
|
||||
};
|
||||
|
||||
const result = await apiAuth.verifyAuth(request);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("verifyAuth rejects requests without valid credentials", async () => {
|
||||
const result = await apiAuth.verifyAuth({
|
||||
cookies: {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
headers: new Headers({ authorization: "Bearer sk-invalid" }),
|
||||
});
|
||||
|
||||
assert.equal(result, "Authentication required");
|
||||
});
|
||||
|
||||
test("isAuthenticated accepts bearer API keys", async () => {
|
||||
const key = await apiKeysDb.createApiKey("integration", "machine1234567890");
|
||||
const request = new Request("https://example.com/api/providers", {
|
||||
headers: { authorization: `Bearer ${key.key}` },
|
||||
});
|
||||
|
||||
const result = await apiAuth.isAuthenticated(request);
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test("isAuthenticated returns false without valid credentials", async () => {
|
||||
const request = new Request("https://example.com/api/providers");
|
||||
|
||||
const result = await apiAuth.isAuthenticated(request);
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test("isAuthRequired is disabled when requireLogin is false", async () => {
|
||||
await localDb.updateSettings({ requireLogin: false });
|
||||
|
||||
const result = await apiAuth.isAuthRequired();
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test("isAuthRequired is disabled while no password exists", async () => {
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const result = await apiAuth.isAuthRequired();
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test("isAuthRequired stays enabled when a password exists", async () => {
|
||||
await localDb.updateSettings({ requireLogin: true, password: "hashed-password" });
|
||||
|
||||
const result = await apiAuth.isAuthRequired();
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test("isAuthRequired stays enabled when INITIAL_PASSWORD is present", async () => {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const result = await apiAuth.isAuthRequired();
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
54
tests/unit/call-log-cap.test.mjs
Normal file
54
tests/unit/call-log-cap.test.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-calllogs-cap-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
const callLogs = await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
callLogs.invalidateCallLogsMaxCache();
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("call logs respect the configurable maxCallLogs setting", async () => {
|
||||
await localDb.updateSettings({ maxCallLogs: 3 });
|
||||
callLogs.invalidateCallLogsMaxCache();
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await callLogs.saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 200,
|
||||
model: `model-${i}`,
|
||||
provider: "openai",
|
||||
duration: i,
|
||||
requestBody: { index: i },
|
||||
responseBody: { ok: true, index: i },
|
||||
});
|
||||
}
|
||||
|
||||
const logs = await callLogs.getCallLogs({ limit: 10 });
|
||||
|
||||
assert.equal(logs.length, 3);
|
||||
assert.deepEqual(
|
||||
logs.map((entry) => entry.model),
|
||||
["model-5", "model-4", "model-3"]
|
||||
);
|
||||
});
|
||||
@@ -37,6 +37,7 @@ describe("CLI_TOOL_IDS", () => {
|
||||
"droid",
|
||||
"openclaw",
|
||||
"cursor",
|
||||
"windsurf",
|
||||
"cline",
|
||||
"kilo",
|
||||
"continue",
|
||||
@@ -160,6 +161,15 @@ describe("continue tool — no binary required", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("windsurf tool — guide-only integration", () => {
|
||||
it("should report installed=true without requiring a local binary", async () => {
|
||||
const result = await getCliRuntimeStatus("windsurf");
|
||||
assert.equal(result.installed, true);
|
||||
assert.equal(result.runnable, true);
|
||||
assert.equal(result.reason, "not_required");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveOpencodeConfigPath — cross-platform ─────────────────
|
||||
|
||||
const { resolveOpencodeConfigPath: resolveOpencodeConfigPathFn } =
|
||||
|
||||
180
tests/unit/display-and-error-utils.test.mjs
Normal file
180
tests/unit/display-and-error-utils.test.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { toJsonErrorPayload } = await import("../../src/shared/utils/upstreamError.ts");
|
||||
const { createErrorResponse, createErrorResponseFromUnknown } =
|
||||
await import("../../src/lib/api/errorResponse.ts");
|
||||
const { getAccountDisplayName, getProviderDisplayName } =
|
||||
await import("../../src/lib/display/names.ts");
|
||||
|
||||
test("toJsonErrorPayload: preserves upstream error objects that already have error payloads", () => {
|
||||
const payload = {
|
||||
error: {
|
||||
message: "provider exploded",
|
||||
code: "quota_exceeded",
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(toJsonErrorPayload(payload), payload);
|
||||
});
|
||||
|
||||
test("toJsonErrorPayload: normalizes object payloads with string error", () => {
|
||||
assert.deepEqual(toJsonErrorPayload({ error: "plain provider error" }), {
|
||||
error: {
|
||||
message: "plain provider error",
|
||||
type: "upstream_error",
|
||||
code: "upstream_error",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("toJsonErrorPayload: wraps plain objects under error key", () => {
|
||||
assert.deepEqual(toJsonErrorPayload({ status: 503, message: "backend down" }), {
|
||||
error: {
|
||||
status: 503,
|
||||
message: "backend down",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("toJsonErrorPayload: parses JSON strings recursively", () => {
|
||||
const raw = JSON.stringify({ error: { message: "nested json", code: "bad_request" } });
|
||||
assert.deepEqual(toJsonErrorPayload(raw), {
|
||||
error: {
|
||||
message: "nested json",
|
||||
code: "bad_request",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("toJsonErrorPayload: falls back for blank strings and unsupported values", () => {
|
||||
const fallback = {
|
||||
error: {
|
||||
message: "custom fallback",
|
||||
type: "upstream_error",
|
||||
code: "upstream_error",
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(toJsonErrorPayload(" ", "custom fallback"), fallback);
|
||||
assert.deepEqual(toJsonErrorPayload(null, "custom fallback"), fallback);
|
||||
});
|
||||
|
||||
test("toJsonErrorPayload: converts non-JSON strings into normalized error payloads", () => {
|
||||
assert.deepEqual(toJsonErrorPayload("gateway timeout"), {
|
||||
error: {
|
||||
message: "gateway timeout",
|
||||
type: "upstream_error",
|
||||
code: "upstream_error",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("createErrorResponse: infers error types from status and preserves details", async () => {
|
||||
const response = createErrorResponse({
|
||||
status: 409,
|
||||
message: "Conflict detected",
|
||||
details: { field: "name" },
|
||||
});
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.equal(body.error.message, "Conflict detected");
|
||||
assert.equal(body.error.type, "conflict");
|
||||
assert.deepEqual(body.error.details, { field: "name" });
|
||||
assert.match(body.requestId, /^[0-9a-f-]{36}$/i);
|
||||
});
|
||||
|
||||
test("createErrorResponse: uses explicit type when provided", async () => {
|
||||
const response = createErrorResponse({
|
||||
status: 418,
|
||||
message: "teapot",
|
||||
type: "not_found",
|
||||
});
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(body.error.type, "not_found");
|
||||
});
|
||||
|
||||
test("createErrorResponseFromUnknown: normalizes typed errors", async () => {
|
||||
const response = createErrorResponseFromUnknown({
|
||||
message: "db exploded",
|
||||
status: 503,
|
||||
type: "server_error",
|
||||
details: { retryable: true },
|
||||
});
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(body.error.message, "db exploded");
|
||||
assert.equal(body.error.type, "server_error");
|
||||
assert.deepEqual(body.error.details, { retryable: true });
|
||||
});
|
||||
|
||||
test("createErrorResponseFromUnknown: falls back for non-object errors", async () => {
|
||||
const response = createErrorResponseFromUnknown("boom", "fallback message");
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.equal(body.error.message, "fallback message");
|
||||
assert.equal(body.error.type, "server_error");
|
||||
});
|
||||
|
||||
test("getAccountDisplayName: respects priority order and fallback", () => {
|
||||
assert.equal(
|
||||
getAccountDisplayName({
|
||||
id: "abcdef123456",
|
||||
name: "Primary Name",
|
||||
displayName: "Display Name",
|
||||
email: "account@example.com",
|
||||
}),
|
||||
"Primary Name"
|
||||
);
|
||||
assert.equal(
|
||||
getAccountDisplayName({
|
||||
id: "abcdef123456",
|
||||
name: " ",
|
||||
displayName: "Display Name",
|
||||
email: "account@example.com",
|
||||
}),
|
||||
"Display Name"
|
||||
);
|
||||
assert.equal(
|
||||
getAccountDisplayName({
|
||||
id: "abcdef123456",
|
||||
name: null,
|
||||
displayName: " ",
|
||||
email: "account@example.com",
|
||||
}),
|
||||
"account@example.com"
|
||||
);
|
||||
assert.equal(getAccountDisplayName({ id: "abcdef123456" }), "Account #abcdef");
|
||||
assert.equal(getAccountDisplayName(null), "Unknown Account");
|
||||
});
|
||||
|
||||
test("getProviderDisplayName: prefers node metadata and simplifies compatible IDs", () => {
|
||||
assert.equal(
|
||||
getProviderDisplayName("openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441", {
|
||||
name: "Friendly Node",
|
||||
prefix: "ignored-prefix",
|
||||
}),
|
||||
"Friendly Node"
|
||||
);
|
||||
assert.equal(
|
||||
getProviderDisplayName("anthropic-compatible-responses-02669115-2545-4896-b003-cb4dac09d441", {
|
||||
name: " ",
|
||||
prefix: "Anthropic Prefix",
|
||||
}),
|
||||
"Anthropic Prefix"
|
||||
);
|
||||
assert.equal(
|
||||
getProviderDisplayName("openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441"),
|
||||
"Compatible (openai)"
|
||||
);
|
||||
assert.equal(
|
||||
getProviderDisplayName("anthropic-compatible-responses-02669115-2545-4896-b003-cb4dac09d441"),
|
||||
"Compatible (anthropic)"
|
||||
);
|
||||
assert.equal(getProviderDisplayName(undefined), "Unknown Provider");
|
||||
assert.equal(getProviderDisplayName("plain-provider-id"), "plain-provider-id");
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-login-bootstrap-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
@@ -22,12 +23,18 @@ test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
bcrypt.hash = originalHash;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const originalHash = bcrypt.hash;
|
||||
|
||||
test("public login bootstrap route exposes the metadata the login page consumes", async () => {
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
@@ -81,3 +88,88 @@ test("public login bootstrap route reports stored password metadata and disabled
|
||||
setupComplete: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("public login bootstrap route POST rejects invalid JSON bodies", async () => {
|
||||
const request = new Request("http://localhost/api/settings/require-login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{ invalid json",
|
||||
});
|
||||
|
||||
const response = await route.POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(body.error.message, "Invalid request");
|
||||
assert.deepEqual(body.error.details, [{ field: "body", message: "Invalid JSON body" }]);
|
||||
});
|
||||
|
||||
test("public login bootstrap route POST rejects empty updates", async () => {
|
||||
const request = new Request("http://localhost/api/settings/require-login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const response = await route.POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(body.error.message, "Invalid request");
|
||||
assert.match(body.error.details[0].message, /No valid fields to update/);
|
||||
});
|
||||
|
||||
test("public login bootstrap route POST updates requireLogin without forcing password", async () => {
|
||||
const request = new Request("http://localhost/api/settings/require-login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ requireLogin: false }),
|
||||
});
|
||||
|
||||
const response = await route.POST(request);
|
||||
const body = await response.json();
|
||||
const settings = await settingsDb.getSettings();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(body, { success: true });
|
||||
assert.equal(settings.requireLogin, false);
|
||||
assert.equal(settings.password, undefined);
|
||||
});
|
||||
|
||||
test("public login bootstrap route POST hashes and stores passwords", async () => {
|
||||
const password = "super-secret";
|
||||
const request = new Request("http://localhost/api/settings/require-login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ requireLogin: true, password }),
|
||||
});
|
||||
|
||||
const response = await route.POST(request);
|
||||
const body = await response.json();
|
||||
const settings = await settingsDb.getSettings();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(body, { success: true });
|
||||
assert.equal(settings.requireLogin, true);
|
||||
assert.ok(settings.password);
|
||||
assert.notEqual(settings.password, password);
|
||||
assert.equal(await bcrypt.compare(password, settings.password), true);
|
||||
});
|
||||
|
||||
test("public login bootstrap route POST returns 500 when hashing fails", async () => {
|
||||
bcrypt.hash = async () => {
|
||||
throw new Error("hash failed");
|
||||
};
|
||||
|
||||
const request = new Request("http://localhost/api/settings/require-login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ password: "super-secret" }),
|
||||
});
|
||||
|
||||
const response = await route.POST(request);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(body, { error: "hash failed" });
|
||||
});
|
||||
|
||||
186
tests/unit/model-combo-mappings-db.test.mjs
Normal file
186
tests/unit/model-combo-mappings-db.test.mjs
Normal file
@@ -0,0 +1,186 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-model-combo-db-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
const mappingsDb = await import("../../src/lib/db/modelComboMappings.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createCombo(name, model) {
|
||||
return combosDb.createCombo({
|
||||
name,
|
||||
models: [{ provider: "openai", model }],
|
||||
strategy: "priority",
|
||||
config: { temperature: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
test("model combo mappings CRUD joins combo names and preserves ordering", async () => {
|
||||
const comboA = await createCombo("alpha", "gpt-4o");
|
||||
const comboB = await createCombo("beta", "claude-sonnet-4");
|
||||
|
||||
const first = await mappingsDb.createModelComboMapping({
|
||||
pattern: "gpt-*",
|
||||
comboId: comboA.id,
|
||||
priority: 20,
|
||||
description: "primary",
|
||||
});
|
||||
const second = await mappingsDb.createModelComboMapping({
|
||||
pattern: "claude-*",
|
||||
comboId: comboB.id,
|
||||
priority: 20,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const all = await mappingsDb.getModelComboMappings();
|
||||
|
||||
assert.equal(all.length, 2);
|
||||
assert.equal(all[0].id, first.id);
|
||||
assert.equal(all[0].comboName, "alpha");
|
||||
assert.equal(all[0].enabled, true);
|
||||
assert.equal(all[0].description, "primary");
|
||||
assert.equal(all[1].id, second.id);
|
||||
assert.equal(all[1].comboName, "beta");
|
||||
assert.equal(all[1].enabled, false);
|
||||
|
||||
const fetched = await mappingsDb.getModelComboMappingById(first.id);
|
||||
assert.equal(fetched?.pattern, "gpt-*");
|
||||
assert.equal(fetched?.comboName, "alpha");
|
||||
assert.equal(fetched?.priority, 20);
|
||||
});
|
||||
|
||||
test("updateModelComboMapping merges fields and returns the refreshed mapping", async () => {
|
||||
const comboA = await createCombo("alpha", "gpt-4o");
|
||||
const comboB = await createCombo("beta", "claude-sonnet-4");
|
||||
|
||||
const created = await mappingsDb.createModelComboMapping({
|
||||
pattern: "gpt-*",
|
||||
comboId: comboA.id,
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
const updated = await mappingsDb.updateModelComboMapping(created.id, {
|
||||
pattern: "claude-*",
|
||||
comboId: comboB.id,
|
||||
priority: 99,
|
||||
enabled: false,
|
||||
description: "rerouted",
|
||||
});
|
||||
|
||||
assert.ok(updated);
|
||||
assert.equal(updated?.id, created.id);
|
||||
assert.equal(updated?.pattern, "claude-*");
|
||||
assert.equal(updated?.comboId, comboB.id);
|
||||
assert.equal(updated?.comboName, "beta");
|
||||
assert.equal(updated?.priority, 99);
|
||||
assert.equal(updated?.enabled, false);
|
||||
assert.equal(updated?.description, "rerouted");
|
||||
assert.notEqual(updated?.updatedAt, created.updatedAt);
|
||||
});
|
||||
|
||||
test("updateModelComboMapping returns null for unknown ids", async () => {
|
||||
const updated = await mappingsDb.updateModelComboMapping("missing-id", {
|
||||
pattern: "gpt-*",
|
||||
});
|
||||
|
||||
assert.equal(updated, null);
|
||||
});
|
||||
|
||||
test("deleteModelComboMapping reports whether a row existed", async () => {
|
||||
const combo = await createCombo("alpha", "gpt-4o");
|
||||
const created = await mappingsDb.createModelComboMapping({
|
||||
pattern: "gpt-*",
|
||||
comboId: combo.id,
|
||||
});
|
||||
|
||||
assert.equal(await mappingsDb.deleteModelComboMapping(created.id), true);
|
||||
assert.equal(await mappingsDb.deleteModelComboMapping(created.id), false);
|
||||
assert.equal(await mappingsDb.getModelComboMappingById(created.id), null);
|
||||
});
|
||||
|
||||
test("resolveComboForModel returns the highest-priority enabled combo", async () => {
|
||||
const fallbackCombo = await createCombo("fallback", "gpt-4o-mini");
|
||||
const priorityCombo = await createCombo("priority", "gpt-4o");
|
||||
const disabledCombo = await createCombo("disabled", "gpt-4.1");
|
||||
|
||||
await mappingsDb.createModelComboMapping({
|
||||
pattern: "*",
|
||||
comboId: fallbackCombo.id,
|
||||
priority: 1,
|
||||
});
|
||||
await mappingsDb.createModelComboMapping({
|
||||
pattern: "gpt-4*",
|
||||
comboId: priorityCombo.id,
|
||||
priority: 10,
|
||||
});
|
||||
await mappingsDb.createModelComboMapping({
|
||||
pattern: "gpt-*",
|
||||
comboId: disabledCombo.id,
|
||||
priority: 100,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const resolved = await mappingsDb.resolveComboForModel("gpt-4o");
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.name, "priority");
|
||||
assert.deepEqual(resolved.models, [{ provider: "openai", model: "gpt-4o" }]);
|
||||
});
|
||||
|
||||
test("resolveComboForModel skips corrupted combo payloads and keeps scanning", async () => {
|
||||
const brokenCombo = await createCombo("broken", "gpt-4o");
|
||||
const fallbackCombo = await createCombo("fallback", "gpt-4o-mini");
|
||||
|
||||
await mappingsDb.createModelComboMapping({
|
||||
pattern: "gpt-4*",
|
||||
comboId: brokenCombo.id,
|
||||
priority: 10,
|
||||
});
|
||||
await mappingsDb.createModelComboMapping({
|
||||
pattern: "gpt-*",
|
||||
comboId: fallbackCombo.id,
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
const db = core.getDbInstance();
|
||||
db.prepare("UPDATE combos SET data = ? WHERE id = ?").run("{not-json", brokenCombo.id);
|
||||
|
||||
const resolved = await mappingsDb.resolveComboForModel("gpt-4o");
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.name, "fallback");
|
||||
});
|
||||
|
||||
test("resolveComboForModel returns null when nothing matches", async () => {
|
||||
const combo = await createCombo("alpha", "gpt-4o");
|
||||
|
||||
await mappingsDb.createModelComboMapping({
|
||||
pattern: "claude-*",
|
||||
comboId: combo.id,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const resolved = await mappingsDb.resolveComboForModel("gpt-4o");
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
202
tests/unit/provider-validation-branches.test.mjs
Normal file
202
tests/unit/provider-validation-branches.test.mjs
Normal file
@@ -0,0 +1,202 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("validateProviderApiKey rejects missing provider or API key", async () => {
|
||||
const result = await validateProviderApiKey({ provider: "", apiKey: "" });
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Provider and API key required");
|
||||
assert.equal(result.unsupported, false);
|
||||
});
|
||||
|
||||
test("validateProviderApiKey returns unsupported for unknown providers", async () => {
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "definitely-unknown-provider",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Provider validation not supported");
|
||||
assert.equal(result.unsupported, true);
|
||||
});
|
||||
|
||||
test("openai-compatible validation reports missing base URL", async () => {
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "openai-compatible-missing-base",
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error, /No base URL configured/i);
|
||||
});
|
||||
|
||||
test("openai-compatible validation accepts rate-limited /models responses", async () => {
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
return new Response(JSON.stringify({ error: "rate limited" }), { status: 429 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "openai-compatible-rate-limit",
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: { baseUrl: "https://api.example.com/v1" },
|
||||
});
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.method, "models_endpoint");
|
||||
assert.match(result.warning, /Rate limited/i);
|
||||
assert.deepEqual(calls, ["https://api.example.com/v1/models"]);
|
||||
});
|
||||
|
||||
test("openai-compatible validation treats chat 400 as authenticated fallback", async () => {
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).endsWith("/models")) {
|
||||
return new Response(JSON.stringify({ error: "server error" }), { status: 500 });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: "bad model" }), { status: 400 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "openai-compatible-fallback-chat",
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
validationModelId: "custom-model",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.method, "inference_available");
|
||||
assert.match(result.warning, /Model ID may be invalid/i);
|
||||
assert.deepEqual(calls, [
|
||||
"https://api.example.com/v1/models",
|
||||
"https://api.example.com/v1/chat/completions",
|
||||
]);
|
||||
});
|
||||
|
||||
test("openai-compatible validation returns actionable connection failure when probes fail", async () => {
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("socket hang up");
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "openai-compatible-network-error",
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
validationModelId: "custom-model",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Connection failed while testing /chat/completions");
|
||||
});
|
||||
|
||||
test("anthropic-compatible validation requires a base URL", async () => {
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "anthropic-compatible-no-base",
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error, /No base URL configured/i);
|
||||
});
|
||||
|
||||
test("anthropic-compatible validation rejects invalid keys from /models", async () => {
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
return new Response(JSON.stringify({ error: "forbidden" }), { status: 403 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "anthropic-compatible-bad-key",
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: { baseUrl: "https://api.example.com/v1/messages" },
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Invalid API key");
|
||||
assert.deepEqual(calls, ["https://api.example.com/v1/models"]);
|
||||
});
|
||||
|
||||
test("anthropic-compatible validation falls back to /messages and treats 400 as auth success", async () => {
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).endsWith("/models")) {
|
||||
throw new Error("models endpoint unavailable");
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: "bad request" }), { status: 400 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "anthropic-compatible-fallback",
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.example.com/v1/messages",
|
||||
validationModelId: "claude-custom",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.error, null);
|
||||
assert.deepEqual(calls, [
|
||||
"https://api.example.com/v1/models",
|
||||
"https://api.example.com/v1/messages",
|
||||
]);
|
||||
});
|
||||
|
||||
test("registry openai-like providers report unsupported validation endpoints on 404 chat probes", async () => {
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
return new Response(JSON.stringify({ error: "not found" }), { status: 404 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "openai",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Provider validation endpoint not supported");
|
||||
assert.deepEqual(calls, [
|
||||
"https://api.openai.com/v1/models",
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
]);
|
||||
});
|
||||
|
||||
test("gemini validation rejects invalid API keys", async () => {
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "gemini",
|
||||
apiKey: "bad-key",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Invalid API key");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0], /generativelanguage\.googleapis\.com/);
|
||||
assert.match(calls[0], /key=bad-key/);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { getModelInfoCore } from "../../open-sse/services/model.ts";
|
||||
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
|
||||
import { getStaticModelsForProvider } from "../../src/app/api/providers/[id]/models/route.ts";
|
||||
|
||||
test("T28: gemini catalog includes preview models from 9router", () => {
|
||||
const geminiIds = REGISTRY.gemini.models.map((m) => m.id);
|
||||
@@ -14,6 +15,20 @@ test("T28: gemini catalog includes preview models from 9router", () => {
|
||||
assert.ok(geminiCliIds.includes("gemini-3-flash-preview"));
|
||||
});
|
||||
|
||||
test("T28: antigravity static catalog includes Gemini 3.1 preview fallbacks", () => {
|
||||
const staticIds = (getStaticModelsForProvider("antigravity") || []).map((m) => m.id);
|
||||
|
||||
assert.ok(staticIds.includes("gemini-3.1-pro-preview"));
|
||||
assert.ok(staticIds.includes("gemini-3.1-flash-lite-preview"));
|
||||
});
|
||||
|
||||
test("T28: qwen registry uses DashScope-compatible base URL", () => {
|
||||
assert.equal(
|
||||
REGISTRY.qwen.baseUrl,
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
test("T28: vertex catalog includes partner models when vertex executor is available", () => {
|
||||
const vertexIds = REGISTRY.vertex.models.map((m) => m.id);
|
||||
|
||||
|
||||
@@ -47,7 +47,11 @@ test("T38: modelSpecs exposes centralized helpers with alias and prefix lookup",
|
||||
assert.equal(typeof MODEL_SPECS["gemini-3.1-pro-high"], "object");
|
||||
assert.equal(getModelSpec("gemini-3-pro-high").maxOutputTokens, 131072);
|
||||
assert.equal(getModelSpec("gemini-3-flash-preview").maxOutputTokens, 65536);
|
||||
assert.equal(getModelSpec("gemini-3.1-pro-preview").maxOutputTokens, 131072);
|
||||
assert.equal(getModelSpec("gemini-3.1-pro-preview-customtools").maxOutputTokens, 131072);
|
||||
assert.equal(resolveModelAlias("gemini-3-pro-low"), "gemini-3.1-pro-low");
|
||||
assert.equal(resolveModelAlias("gemini-3.1-pro-preview"), "gemini-3.1-pro-high");
|
||||
assert.equal(resolveModelAlias("gemini-3.1-pro-preview-customtools"), "gemini-3.1-pro-high");
|
||||
assert.equal(getDefaultThinkingBudget("gemini-3.1-pro-high"), 24576);
|
||||
assert.equal(capThinkingBudget("gemini-3.1-pro-low", 50000), 16000);
|
||||
});
|
||||
|
||||
@@ -65,3 +65,18 @@ test("T40: OpenCode config generator includes endpoint and selected API key", ()
|
||||
assert.equal(mergedConfig.providers.omniroute.baseURL, "http://localhost:20128/v1");
|
||||
assert.equal(mergedConfig.providers.omniroute.apiKey, "sk_test_opencode");
|
||||
});
|
||||
|
||||
test("T40: Windsurf card documents current official limitations honestly", () => {
|
||||
const windsurf = CLI_TOOLS.windsurf;
|
||||
assert.ok(windsurf, "Windsurf tool card must exist");
|
||||
assert.equal(windsurf.configType, "guide");
|
||||
|
||||
const notesText = (windsurf.notes || [])
|
||||
.map((note) => note?.text || "")
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
assert.match(notesText, /byok/);
|
||||
assert.match(notesText, /custom openai-compatible provider/);
|
||||
assert.match(notesText, /proxy documentation is for network proxies/);
|
||||
});
|
||||
|
||||
192
tests/unit/tool-request-sanitization.test.mjs
Normal file
192
tests/unit/tool-request-sanitization.test.mjs
Normal file
@@ -0,0 +1,192 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
coerceSchemaNumericFields,
|
||||
sanitizeToolDescription,
|
||||
coerceToolSchemas,
|
||||
sanitizeToolDescriptions,
|
||||
injectEmptyReasoningContentForToolCalls,
|
||||
} = await import("../../open-sse/translator/helpers/schemaCoercion.ts");
|
||||
const { translateRequest } = await import("../../open-sse/translator/index.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
test("tool sanitization: coerces numeric JSON Schema fields recursively", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", minimum: "1", maximum: "10" },
|
||||
items: {
|
||||
type: "array",
|
||||
minItems: "2",
|
||||
items: { type: "string", minLength: "3" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = coerceSchemaNumericFields(schema);
|
||||
assert.equal(result.properties.count.minimum, 1);
|
||||
assert.equal(result.properties.count.maximum, 10);
|
||||
assert.equal(result.properties.items.minItems, 2);
|
||||
assert.equal(result.properties.items.items.minLength, 3);
|
||||
});
|
||||
|
||||
test("tool sanitization: preserves non-numeric JSON Schema strings", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string", minimum: "abc" },
|
||||
},
|
||||
};
|
||||
|
||||
const result = coerceSchemaNumericFields(schema);
|
||||
assert.equal(result.properties.value.minimum, "abc");
|
||||
});
|
||||
|
||||
test("tool sanitization: normalizes descriptions across OpenAI, Claude, and Gemini shapes", () => {
|
||||
const openAITool = sanitizeToolDescription({
|
||||
type: "function",
|
||||
function: { name: "sum", description: null, parameters: {} },
|
||||
});
|
||||
const claudeTool = sanitizeToolDescription({
|
||||
name: "sum",
|
||||
description: 42,
|
||||
input_schema: { type: "object" },
|
||||
});
|
||||
const geminiTool = sanitizeToolDescription({
|
||||
functionDeclarations: [{ name: "sum", description: false, parameters: {} }],
|
||||
});
|
||||
|
||||
assert.equal(openAITool.function.description, "");
|
||||
assert.equal(claudeTool.description, "42");
|
||||
assert.equal(geminiTool.functionDeclarations[0].description, "false");
|
||||
});
|
||||
|
||||
test("tool sanitization: coerces schemas and descriptions in tool arrays", () => {
|
||||
const tools = sanitizeToolDescriptions(
|
||||
coerceToolSchemas([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "sum",
|
||||
description: 5,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", minimum: "1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
assert.equal(tools[0].function.description, "5");
|
||||
assert.equal(tools[0].function.parameters.properties.count.minimum, 1);
|
||||
});
|
||||
|
||||
test("translateRequest sanitizes tools before Claude output", () => {
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"claude-sonnet-4-6",
|
||||
{
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "sum",
|
||||
description: null,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", minimum: "1", maximum: "9" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"claude"
|
||||
);
|
||||
|
||||
assert.equal(translated.tools[0].description, "");
|
||||
assert.equal(translated.tools[0].input_schema.properties.count.minimum, 1);
|
||||
assert.equal(translated.tools[0].input_schema.properties.count.maximum, 9);
|
||||
});
|
||||
|
||||
test("translateRequest sanitizes OpenAI tool payloads on passthrough", () => {
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI,
|
||||
"gpt-5.2",
|
||||
{
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "sum",
|
||||
description: 7,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", minimum: "2" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"openai"
|
||||
);
|
||||
|
||||
assert.equal(translated.tools[0].function.description, "7");
|
||||
assert.equal(translated.tools[0].function.parameters.properties.count.minimum, 2);
|
||||
});
|
||||
|
||||
test("tool sanitization: injects empty reasoning_content only for DeepSeek tool-call history", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "sum", arguments: "{}" } }],
|
||||
},
|
||||
];
|
||||
|
||||
const deepseekMessages = injectEmptyReasoningContentForToolCalls(messages, "deepseek");
|
||||
const openaiMessages = injectEmptyReasoningContentForToolCalls(messages, "openai");
|
||||
|
||||
assert.equal(deepseekMessages[1].reasoning_content, "");
|
||||
assert.equal(openaiMessages[1].reasoning_content, undefined);
|
||||
});
|
||||
|
||||
test("translateRequest injects reasoning_content for DeepSeek assistant tool calls", () => {
|
||||
const translated = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI,
|
||||
"deepseek-reasoner",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{ id: "call_1", type: "function", function: { name: "sum", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: "3" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"deepseek"
|
||||
);
|
||||
|
||||
assert.equal(translated.messages[1].reasoning_content, "");
|
||||
});
|
||||
293
tests/unit/usage-analytics.test.mjs
Normal file
293
tests/unit/usage-analytics.test.mjs
Normal file
@@ -0,0 +1,293 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-usage-analytics-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
|
||||
const usageStats = await import("../../src/lib/usage/usageStats.ts");
|
||||
const { calculateCost } = await import("../../src/lib/usage/costCalculator.ts");
|
||||
const { LOG_FILE } = await import("../../src/lib/usage/migrations.ts");
|
||||
|
||||
function clearPendingRequests() {
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
for (const key of Object.keys(pending.byModel)) delete pending.byModel[key];
|
||||
for (const key of Object.keys(pending.byAccount)) delete pending.byAccount[key];
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
clearPendingRequests();
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("usage history persists entries and supports filtering and usageDb compatibility", async () => {
|
||||
const recentTimestamp = new Date().toISOString();
|
||||
const olderTimestamp = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "provider-a",
|
||||
model: "model-a",
|
||||
connectionId: "conn-a",
|
||||
apiKeyId: "key-a",
|
||||
apiKeyName: "Key A",
|
||||
tokens: {
|
||||
input: 10,
|
||||
output: 5,
|
||||
cacheRead: 2,
|
||||
cacheCreation: 1,
|
||||
reasoning: 3,
|
||||
},
|
||||
status: "success",
|
||||
success: true,
|
||||
latencyMs: 120,
|
||||
timeToFirstTokenMs: 30,
|
||||
timestamp: recentTimestamp,
|
||||
});
|
||||
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "provider-b",
|
||||
model: "model-b",
|
||||
connectionId: "conn-b",
|
||||
tokens: {
|
||||
prompt_tokens: 20,
|
||||
completion_tokens: 7,
|
||||
cached_tokens: 4,
|
||||
cache_creation_input_tokens: 2,
|
||||
reasoning_tokens: 1,
|
||||
},
|
||||
status: "error",
|
||||
success: false,
|
||||
latencyMs: 400,
|
||||
errorCode: "rate_limited",
|
||||
timestamp: olderTimestamp,
|
||||
});
|
||||
|
||||
const filtered = await usageHistory.getUsageHistory({
|
||||
provider: "provider-a",
|
||||
startDate: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
});
|
||||
const all = await usageHistory.getUsageDb();
|
||||
|
||||
assert.equal(filtered.length, 1);
|
||||
assert.equal(filtered[0].provider, "provider-a");
|
||||
assert.equal(filtered[0].tokens.input, 10);
|
||||
assert.equal(filtered[0].tokens.output, 5);
|
||||
assert.equal(filtered[0].tokens.cacheRead, 2);
|
||||
assert.equal(filtered[0].tokens.cacheCreation, 1);
|
||||
assert.equal(filtered[0].tokens.reasoning, 3);
|
||||
assert.equal(filtered[0].timeToFirstTokenMs, 30);
|
||||
|
||||
assert.equal(all.data.history.length, 2);
|
||||
assert.equal(all.data.history[0].provider, "provider-b");
|
||||
assert.equal(all.data.history[1].provider, "provider-a");
|
||||
assert.equal(all.data.history[0].success, false);
|
||||
assert.equal(all.data.history[1].success, true);
|
||||
});
|
||||
|
||||
test("getModelLatencyStats aggregates success rate and latency percentiles", async () => {
|
||||
const now = Date.now();
|
||||
const entries = [
|
||||
{ latencyMs: 100, success: true },
|
||||
{ latencyMs: 200, success: true },
|
||||
{ latencyMs: 400, success: true },
|
||||
{ latencyMs: 900, success: false },
|
||||
];
|
||||
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "latency-provider",
|
||||
model: "latency-model",
|
||||
success: entry.success,
|
||||
latencyMs: entry.latencyMs,
|
||||
timestamp: new Date(now - index * 60 * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const stats = await usageHistory.getModelLatencyStats({
|
||||
windowHours: 1,
|
||||
minSamples: 2,
|
||||
maxRows: 50,
|
||||
});
|
||||
|
||||
const entry = stats["latency-provider/latency-model"];
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.totalRequests, 4);
|
||||
assert.equal(entry.successfulRequests, 3);
|
||||
assert.equal(entry.successRate, 0.75);
|
||||
assert.equal(entry.avgLatencyMs, 233);
|
||||
assert.equal(entry.p50LatencyMs, 200);
|
||||
assert.equal(entry.p95LatencyMs, 400);
|
||||
assert.equal(entry.p99LatencyMs, 400);
|
||||
assert.ok(entry.latencyStdDev > 0);
|
||||
});
|
||||
|
||||
test("getModelLatencyStats falls back to all latencies when successful sample count is too small", async () => {
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "fallback-provider",
|
||||
model: "fallback-model",
|
||||
success: true,
|
||||
latencyMs: 100,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "fallback-provider",
|
||||
model: "fallback-model",
|
||||
success: false,
|
||||
latencyMs: 500,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const stats = await usageHistory.getModelLatencyStats({
|
||||
windowHours: 1,
|
||||
minSamples: 2,
|
||||
});
|
||||
|
||||
const entry = stats["fallback-provider/fallback-model"];
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.successRate, 0.5);
|
||||
assert.equal(entry.avgLatencyMs, 300);
|
||||
assert.equal(entry.p50LatencyMs, 500);
|
||||
});
|
||||
|
||||
test("getUsageStats aggregates totals, buckets, pending requests, and cost breakdowns", async () => {
|
||||
await localDb.updatePricing({
|
||||
"pricing-provider": {
|
||||
"pricing-model": {
|
||||
input: 1000,
|
||||
cached: 100,
|
||||
output: 2000,
|
||||
reasoning: 3000,
|
||||
cache_creation: 1500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "pricing-provider",
|
||||
authType: "apikey",
|
||||
name: "Primary Account",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
const recentTokens = {
|
||||
input: 100,
|
||||
output: 50,
|
||||
cacheRead: 20,
|
||||
cacheCreation: 10,
|
||||
reasoning: 5,
|
||||
};
|
||||
const oldTokens = {
|
||||
input: 40,
|
||||
output: 10,
|
||||
cacheRead: 0,
|
||||
cacheCreation: 0,
|
||||
reasoning: 0,
|
||||
};
|
||||
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "pricing-provider",
|
||||
model: "pricing-model",
|
||||
connectionId: connection.id,
|
||||
apiKeyId: "api-key-1",
|
||||
apiKeyName: "Service Key",
|
||||
tokens: recentTokens,
|
||||
success: true,
|
||||
latencyMs: 150,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "pricing-provider",
|
||||
model: "pricing-model",
|
||||
connectionId: connection.id,
|
||||
apiKeyId: "api-key-1",
|
||||
apiKeyName: "Service Key",
|
||||
tokens: oldTokens,
|
||||
success: true,
|
||||
latencyMs: 80,
|
||||
timestamp: new Date(Date.now() - 20 * 60 * 1000).toISOString(),
|
||||
});
|
||||
|
||||
usageHistory.trackPendingRequest("pricing-model", "pricing-provider", connection.id, true);
|
||||
usageHistory.trackPendingRequest("pricing-model", "pricing-provider", connection.id, true);
|
||||
usageHistory.trackPendingRequest("pricing-model", "pricing-provider", connection.id, false);
|
||||
|
||||
const stats = await usageStats.getUsageStats();
|
||||
const expectedCost =
|
||||
(await calculateCost("pricing-provider", "pricing-model", recentTokens)) +
|
||||
(await calculateCost("pricing-provider", "pricing-model", oldTokens));
|
||||
|
||||
assert.equal(stats.totalRequests, 2);
|
||||
assert.equal(stats.totalPromptTokens, 140);
|
||||
assert.equal(stats.totalCompletionTokens, 60);
|
||||
assert.ok(Math.abs(stats.totalCost - expectedCost) < 1e-9);
|
||||
|
||||
assert.equal(stats.byProvider["pricing-provider"].requests, 2);
|
||||
assert.equal(stats.byProvider["pricing-provider"].promptTokens, 140);
|
||||
assert.equal(stats.byModel["pricing-model (pricing-provider)"].requests, 2);
|
||||
|
||||
const accountKey = "pricing-model (pricing-provider - Primary Account)";
|
||||
assert.equal(stats.byAccount[accountKey].requests, 2);
|
||||
assert.equal(stats.byAccount[accountKey].accountName, "Primary Account");
|
||||
|
||||
assert.equal(stats.byApiKey["Service Key (api-key-1)"].requests, 2);
|
||||
assert.equal(stats.pending.byModel["pricing-model (pricing-provider)"], 1);
|
||||
assert.equal(stats.pending.byAccount[connection.id]["pricing-model (pricing-provider)"], 1);
|
||||
assert.deepEqual(stats.activeRequests, [
|
||||
{
|
||||
model: "pricing-model",
|
||||
provider: "pricing-provider",
|
||||
account: "Primary Account",
|
||||
count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(stats.last10Minutes.length, 10);
|
||||
const recentBucketTotal = stats.last10Minutes.reduce((sum, bucket) => sum + bucket.requests, 0);
|
||||
assert.equal(recentBucketTotal, 1);
|
||||
});
|
||||
|
||||
test("request log appends readable entries and trims to the most recent 200 lines", async () => {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "log-provider",
|
||||
authType: "apikey",
|
||||
name: "Named Account",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
for (let i = 0; i < 205; i++) {
|
||||
await usageHistory.appendRequestLog({
|
||||
model: `model-${i}`,
|
||||
provider: "log-provider",
|
||||
connectionId: connection.id,
|
||||
tokens: { input: i + 1, output: i + 2 },
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
const recent = await usageHistory.getRecentLogs(3);
|
||||
const lines = fs.readFileSync(LOG_FILE, "utf8").trim().split("\n");
|
||||
|
||||
assert.equal(lines.length, 200);
|
||||
assert.equal(recent.length, 3);
|
||||
assert.match(recent[0], /model-204/);
|
||||
assert.match(recent[0], /LOG-PROVIDER/);
|
||||
assert.match(recent[0], /Named Account/);
|
||||
assert.match(recent[0], /205 \| 206 \| 200$/);
|
||||
});
|
||||
Reference in New Issue
Block a user