mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 19:22:32 +03:00
Compare commits
3 Commits
feat/image
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b26eae3b2 | ||
|
|
20ea78c943 | ||
|
|
964a3fe442 |
@@ -0,0 +1 @@
|
||||
- fix(cli): drop the orphaned `resolveOpencodeConfigDir` re-export from `cliRuntime` — it lost its last consumer in #10246 and diverged from the canonical resolver by one directory level (#9985)
|
||||
@@ -442,7 +442,7 @@
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1062,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051,
|
||||
"src/shared/components/ModelSelectModal.tsx": 1138,
|
||||
"src/shared/constants/providers/apikey/gateways.ts": 1255,
|
||||
"src/shared/constants/providers/apikey/gateways.ts": 1250,
|
||||
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387,
|
||||
"_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).",
|
||||
"src/lib/modelCapabilities.ts": 1006,
|
||||
@@ -609,6 +609,5 @@
|
||||
"_rebaseline_2026_08_06_9006_reconcile_onto_tip_2": "PR #9006 (fix/vertex-claude-catalog-dispatch) second rebase-onto-tip reconciliation. Same two files as _rebaseline_2026_08_04_9006_reconcile_onto_tip below, further inherited drift, still not this PR's own growth (verified via git diff against the fresh upstream/release/v3.8.50 merge-base — this PR's own commits still touch neither file): open-sse/executors/base.ts 1623->1640 (+17) and src/sse/handlers/chat.ts 1847->1881 (+34), both measured post-merge via split(\"\\n\").length. More already-merged release/v3.8.50 PRs grew these files without updating their baseline entries (same root cause as every other entry in this chain: fast-gates PR->release does not run check:file-size). No offending branch left to fix.",
|
||||
"_rebaseline_2026_08_12_v3850_basereds_round3": "Base-reds round 3 (#9985, 2026-08-12): ModelSelectModal.tsx 1135->1138 = base drift from the #10198 SWR/build repair (flagged as non-blocking drift by Release-Green run 31634993212, rebaselined here so the PR queue's Fast Quality Gates stop failing on inherited drift); gateways.ts 1215->1250 = base drift from the 08-12 merges (#10131 regolo/naga-ac repair, #9210 void-ai+helixmind) plus this PR restoring the chatanywhere metadata entry that round 2 dropped along with its duplicate (wave3 audited entry, +16 lines; same god-file no-split rationale as the 2026-08-11 annotation). Owner-authorized sweep (/sweep-reds).",
|
||||
"_rebaseline_2026_08_12_proxyfetch_redaction": "Base-reds round 3 (#9985): proxyFetch.ts 1220->1239 (+19) = redactProxyDetailsInMessage() helper closing the credential leak #10032 reintroduced (raw proxy URL with user:password appended to the propagated error, Hard Rule #12); irreducible security fix at the existing error-surface chokepoint. Covered by tests/unit/tls-proxy-context.test.ts (strengthened leak guards).",
|
||||
"_rebaseline_2026_08_12_modelcapabilities_snapshot_routing": "Base-reds round 3 (#9985): modelCapabilities.ts crossed the new-file cap at 1006 (+~10) when the context/max-input-token override lookups were routed through the #9199 bulk snapshot (fixing 323 per-model SQLite reads per catalog prepare — auto-combo-context-advertising guard); cohesive change at the existing resolution chokepoints, not extractable. Covered by tests/unit/auto-combo-context-advertising.test.ts + model-capability-resolution-snapshot-9199.test.ts.",
|
||||
"_rebaseline_2026_08_14_imagetotext_servicekinds": "Image-to-Text category (#10275/#10291): gateways.ts grew 1250→1255 by data lines only — the serviceKinds: [\"llm\", \"imageToText\"] declarations on the openrouter and chutes catalog entries, plus the 3-line comment recording why chutes needs no static dots.ocr entry (passthroughModels discovery). No new logic or branching; the file is a provider catalog of declarative metadata. Splitting a catalog for five lines would be worse than the growth (semantic-families rule)."
|
||||
"_rebaseline_2026_08_12_modelcapabilities_snapshot_routing": "Base-reds round 3 (#9985): modelCapabilities.ts crossed the new-file cap at 1006 (+~10) when the context/max-input-token override lookups were routed through the #9199 bulk snapshot (fixing 323 per-model SQLite reads per catalog prepare — auto-combo-context-advertising guard); cohesive change at the existing resolution chokepoints, not extractable. Covered by tests/unit/auto-combo-context-advertising.test.ts + model-capability-resolution-snapshot-9199.test.ts."
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"_rebaseline_2026_07_28_v3849_release": "75.5 -> 99 (+23.5). Aperto EXIGIDO pelo modo --require-tighten do ratchet: a métrica melhorou de verdade no ciclo v3.8.49. A causa é o workflow assíncrono de tradução, que finalmente alcançou o denominador em EN — as rebaselines anteriores (v3.8.39/.44/.47) foram todas afrouxamentos registrando o atraso das traduções, e agora ele foi pago. O coletor SUBTRAI os placeholders (present - placeholder em scripts/quality/collect-metrics.mjs), então os 317 marcadores __MISSING__ que esta release introduziu para o drift de valor já estão descontados dos 99 — o número é honesto, não inflado por placeholder. Medido pelo collect-metrics do CI no run 30404226939."
|
||||
},
|
||||
"deadExports": {
|
||||
"value": 415,
|
||||
"value": 409,
|
||||
"direction": "down",
|
||||
"_rebaseline_2026_08_09_v3850_post_sweep": "227 -> 230. Measured by npm run check:dead-code on the unmodified release/v3.8.50 tip 382449d593 during the mandatory --full-ci pre-flight. The +3 is inherited cycle drift from the authorized merge sweep; this repair adds no production exports. Rebaseline records the actual tip so ci.yml quality-gate can run, while structural cleanup remains separate debt.",
|
||||
"_rebaseline_2026_07_01_v3843_release": "225->227 (+2). v3.8.43 cycle drift, surfaced in the Quality Ratchet job after eslintWarnings was rebaselined (check:dead-code runs there). 227 = measured by check:dead-code (knip) on the release tip 4635076eb. The 5 CI fixes add 0 dead exports: safeHttpHref in linkify.ts is module-local AND used (called by linkifyText); no new exports; test files are not scanned. Tighten via --update next cycle.",
|
||||
@@ -111,8 +111,7 @@
|
||||
"_rebaseline_2026_06_27_v3838_release": "345->346 (+1). v3.8.38 cycle drift surfaced by the release-green pre-flight (Quality Ratchet does NOT run on PR->release fast-gates). Net +1 inherited from this cycle's feature/fix merges (new executors/providers, compression fidelity-gate module) minus #5138's removal of dead legacy store modules. Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + README + baselines — 0 production-code change. Structural cleanup tracked as debt.",
|
||||
"_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
|
||||
"_rebaseline_2026_08_11_v3850_merge_storm": "230 -> 248. Own drift from the 2026-08-11 merge storm (99 PRs into release/v3.8.50 via authorized sweep): new providers/executors/handlers added dead exports that knip cannot see as used. Measured on the base-fix tip (7ca73697b0 + this repair PR). Owner authorized rebaseline (2026-08-11) — structural cleanup remains separate debt.",
|
||||
"_rebaseline_2026_08_13_v3850_knip_bump": "248 -> 409. NOT code-added dead exports: dependabot bump #10043 (2026-08-13) upgraded knip 6.27.0 -> 6.32.x, and the new knip detects 162 MORE genuinely-unused exports (331 vs 169 deadExports) that 6.27 missed. DEAD_FILES unchanged (78). Reproduced identically on the clean release/v3.8.50 tip 266e39d3 with a fresh knip 6.32 node_modules — so every PR is born red on this gate until the tool change is absorbed. Owner authorized rebaseline (2026-08-13, via base-reds PR #10260). Structural cleanup of the 162 newly-surfaced dead exports remains separate debt.",
|
||||
"_rebaseline_2026_08_14_ocr_imagetotext_series": "OCR/image-to-text series (#10275/#10283/#10287/#10289/#10291): deadExports 409 -> 415. Each PR in the series adds public util/registry exports that are exercised by their unit tests but not yet by a second production caller — normalizeImageBuffer (imageNormalize), MISTRAL_PASSTHROUGH / AZURE_DI_TRANSFORMATION / getOcrTransformation (ocrRegistry), resolveOcrCredentials (v1/ocr route). They are the documented public surface of the new modules and are covered by tests; structural cleanup stays tracked in #3501."
|
||||
"_rebaseline_2026_08_13_v3850_knip_bump": "248 -> 409. NOT code-added dead exports: dependabot bump #10043 (2026-08-13) upgraded knip 6.27.0 -> 6.32.x, and the new knip detects 162 MORE genuinely-unused exports (331 vs 169 deadExports) that 6.27 missed. DEAD_FILES unchanged (78). Reproduced identically on the clean release/v3.8.50 tip 266e39d3 with a fresh knip 6.32 node_modules — so every PR is born red on this gate until the tool change is absorbed. Owner authorized rebaseline (2026-08-13, via base-reds PR #10260). Structural cleanup of the 162 newly-surfaced dead exports remains separate debt."
|
||||
},
|
||||
"cognitiveComplexity": {
|
||||
"value": 1223,
|
||||
|
||||
@@ -289,6 +289,123 @@ single-shot, so usage accounting and semaphore release are not duplicated.
|
||||
|
||||
---
|
||||
|
||||
## 7. Upstream Status Restatement (misstated quota errors)
|
||||
|
||||
**Scope:** one upstream gateway that reports temporary quota exhaustion with the wrong HTTP status.
|
||||
|
||||
**Purpose:** correct a misleading status BEFORE classification, so downstream consumers (fallback engine, combo aggregation, the client-facing response) see the true retryable nature of the failure.
|
||||
|
||||
Some gateways signal TEMPORARY quota exhaustion with a non-retryable HTTP
|
||||
status. `agentrouter.org` returns `403` (sometimes `400`) with a Chinese body
|
||||
(`用户额度不足` / `额度不足`) instead of the standard `429`. Clients like Claude
|
||||
Code treat `403` as permanent and abort the session, and without correction
|
||||
the fallback engine would classify it as `AUTH_ERROR` instead of a quota
|
||||
event.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Registry + matcher: `open-sse/config/upstreamStatusRestatement.ts` — a
|
||||
per-provider list of rules (`{id, fromStatuses, toStatus, textMarkers,
|
||||
excludeMarkers, defaultRetryAfterMs}`), matched via `applyStatusRestatement()`.
|
||||
- Call site: the `providerFailure:` block in `open-sse/handlers/chatCore.ts`
|
||||
(around line 3654), right after `parseUpstreamError()` parses an upstream
|
||||
response with an error HTTP status (`!providerResponse.ok`), and before any
|
||||
classification runs, so every downstream consumer sees the corrected
|
||||
status. Errors embedded inside a `200` SSE stream follow a separate,
|
||||
later stream-parsing path and are **not** covered by this hook today — a
|
||||
known limitation, not yet needed for agentrouter's misstatus (which
|
||||
surfaces as an error HTTP status).
|
||||
- Retry eligibility: `429` is in `RETRY_AFTER_ELIGIBLE_STATUSES`
|
||||
(`open-sse/services/combo/unavailableRetryGate.ts`), so a restated error
|
||||
carries a real retry window instead of surfacing as a dead `403`.
|
||||
- The synthetic `60s` `defaultRetryAfterMs` (`upstreamStatusRestatement.ts`)
|
||||
is only what the restated response tells the **client**; it is not itself
|
||||
the connection's internal cooldown/lockout duration — that is governed
|
||||
separately by whichever mechanism actually handles the restated error
|
||||
(Connection Cooldown's escalating backoff, §2, base `3s` for API-key
|
||||
providers; or Model Lockout, §3, for per-model-quota providers like
|
||||
agentrouter). The router can become eligible to retry internally sooner
|
||||
than the 60s window it advertises to the client — intentional headroom,
|
||||
not a bug.
|
||||
|
||||
Permanent errors (agentrouter's `无权访问模型` — no access to this model) are
|
||||
NEVER restated: `excludeMarkers` vetoes the rule even when `textMarkers` hit,
|
||||
so the error keeps its original status and nothing retries it forever. A
|
||||
separate provider classification rule
|
||||
(`agentrouter-model-access-denied` in `open-sse/config/providerErrorRules.ts`)
|
||||
declares an `auth_error`/scope-`model` match for this text, but it does not
|
||||
fire on the live production path today: the rule only matches `status ===
|
||||
403`, and `checkFallbackError`'s apikey-category `FORBIDDEN` branch
|
||||
(`open-sse/services/accountFallback.ts`) returns early for a plain 403
|
||||
*before* the provider-rule lookup ever runs. In practice a `无权访问模型` 403
|
||||
is handled the same way as the base apikey-provider 403 path (see Connection
|
||||
Cooldown, §2), not as a 6h model lockout. The rule still exists as a
|
||||
declarative classification consumable by future callers of `classifyError`
|
||||
with context — wiring it into the production `checkFallbackError` path is
|
||||
tracked as a follow-up, not yet done.
|
||||
|
||||
Restated quota errors (`额度不足`) do reach a provider rule in production
|
||||
(`agentrouter-user-quota-exhausted`, scope `"connection"`), but `scope` on
|
||||
`ProviderErrorRuleMatch` is currently informational — the persistence path
|
||||
(`checkFallbackError` → `combo.ts`) only consumes `reason` and `cooldownMs`,
|
||||
never `scope`. What actually happens for agentrouter (`passthroughModels:
|
||||
true` → `hasPerModelQuota()` returns `true`) is a **per-model** lockout via
|
||||
`recordModelLockoutFailure()`: the connection itself is never cooled down for
|
||||
this error (`combo.ts` skips `recordProviderCooldown` for 429 when
|
||||
`hasPerModelQuota` is true), so other models on the same account keep being
|
||||
tried — each one burns one call and its own lockout before combo routing
|
||||
moves on. Honoring `scope` end-to-end (so a `"connection"` match actually
|
||||
locks the connection) is tracked as a follow-up.
|
||||
|
||||
### Two-stage design: status restatement, then classification
|
||||
|
||||
Status restatement (`upstreamStatusRestatement.ts`) and provider
|
||||
classification rules (`open-sse/config/providerErrorRules.ts`,
|
||||
`providerRuleRegistry`) are separate registries that both key on provider id
|
||||
and text markers, but they run in different places and serve different
|
||||
purposes: restatement rewrites the HTTP status early in `chatCore.ts`;
|
||||
classification rules pick the fallback `reason` and lock `scope`
|
||||
(`model` / `provider` / `connection`) inside `checkFallbackError()`
|
||||
(`open-sse/services/accountFallback.ts`).
|
||||
|
||||
Classification rules only see full error **text** (needed to match body
|
||||
markers like `额度不足`) for providers listed in the `FULL_TEXT_RULE_PROVIDERS`
|
||||
allowlist in `providerErrorRules.ts` — currently only `"agentrouter"`. For
|
||||
every other provider, `checkFallbackError` hands `getProviderErrorRuleMatch`
|
||||
only the structured error (`{code, type}`), which is enough for
|
||||
header/status/code-based rules but blind to body-text markers. The helper
|
||||
`resolveRuleMatchBody()` performs this selection: full error text for
|
||||
allowlisted providers, the structured error otherwise. Adding a provider to
|
||||
`FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider opt-in — it exists so
|
||||
that the default path for every provider not on the list stays
|
||||
byte-for-byte unchanged.
|
||||
|
||||
### Adding a new quota-misstating gateway
|
||||
|
||||
1. Register one rule array in `statusRestatementRegistry`
|
||||
(`open-sse/config/upstreamStatusRestatement.ts`). Keep `textMarkers`
|
||||
provider-specific; never reuse generic English phrases that collide with
|
||||
`CREDITS_EXHAUSTED_SIGNALS` (`open-sse/services/accountFallback.ts`).
|
||||
2. Optionally register classification rules in
|
||||
`open-sse/config/providerErrorRules.ts` (`providerRuleRegistry`) to pick
|
||||
the right lock scope (`connection` for account-wide quota, `model` for
|
||||
per-model errors). This step only takes effect in production for
|
||||
providers whose rules need the full error text (body markers): add the
|
||||
provider id to `FULL_TEXT_RULE_PROVIDERS` in the same file — otherwise
|
||||
`checkFallbackError` only ever hands the rule the structured
|
||||
`{code, type}` error and a body-text rule will never match live traffic.
|
||||
Rules that match purely on `status`/`headers` (like Opencode's or
|
||||
Minimax's) do not need this opt-in.
|
||||
3. Add unit tests mirroring `tests/unit/upstream-status-restatement.test.ts`
|
||||
and `tests/unit/agentrouter-error-rules.test.ts` (including the
|
||||
not-permanent / not-creditsExhausted guards, and — if the provider needs
|
||||
the allowlist — a test asserting `resolveRuleMatchBody()` returns the
|
||||
full text only for that provider).
|
||||
|
||||
No changes to `chatCore.ts`, `classifyError`, or combo are needed.
|
||||
|
||||
---
|
||||
|
||||
## Other Resilience Features
|
||||
|
||||
- **19 routing strategies** (priority, weighted, round-robin, context-relay, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, fusion, pipeline) — see [AUTO-COMBO.md](../routing/AUTO-COMBO.md).
|
||||
|
||||
@@ -6840,17 +6840,9 @@ paths:
|
||||
- Images
|
||||
summary: Document OCR
|
||||
description: >-
|
||||
Multi-provider document OCR endpoint (Mistral OCR–compatible request
|
||||
and response shape). Accepts a JSON body referencing a document/image
|
||||
and returns extracted text. `model` selects the provider via a
|
||||
`provider/model` prefix (e.g. `mistral/mistral-ocr-latest`,
|
||||
`azure-document-intelligence/prebuilt-read`); a bare model id (e.g.
|
||||
`mistral-ocr-latest`) resolves to its registered provider, and an
|
||||
omitted `model` defaults to Mistral. Azure Document Intelligence is
|
||||
asynchronous upstream — the handler polls the returned operation
|
||||
until it succeeds or fails before responding, so this endpoint can
|
||||
take longer to return for that provider. Success responses carry the
|
||||
`X-OmniRoute-*` cost-telemetry headers.
|
||||
Mistral OCR–compatible document OCR endpoint. Accepts a JSON body
|
||||
referencing a document/image and returns extracted text. Success
|
||||
responses carry the `X-OmniRoute-*` cost-telemetry headers.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
@@ -6862,11 +6854,6 @@ paths:
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
description: >-
|
||||
`provider/model` id or bare model id. Registered ids:
|
||||
`mistral/mistral-ocr-latest`,
|
||||
`azure-document-intelligence/prebuilt-read`. Defaults to
|
||||
`mistral-ocr-latest` when omitted.
|
||||
document:
|
||||
type: object
|
||||
responses:
|
||||
|
||||
@@ -17,7 +17,6 @@ Complete reference for all OmniRoute API endpoints.
|
||||
- [Chat Completions](#chat-completions)
|
||||
- [Embeddings](#embeddings)
|
||||
- [Image Generation](#image-generation)
|
||||
- [Document OCR](#document-ocr)
|
||||
- [List Models](#list-models)
|
||||
- [Provider Plugin Manifest](#provider-plugin-manifest)
|
||||
- [Compatibility Endpoints](#compatibility-endpoints)
|
||||
@@ -200,53 +199,6 @@ GET /v1/images/generations
|
||||
|
||||
---
|
||||
|
||||
## Document OCR
|
||||
|
||||
```bash
|
||||
POST /v1/ocr
|
||||
Authorization: Bearer your-api-key
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "mistral/mistral-ocr-latest",
|
||||
"document": {
|
||||
"type": "document_url",
|
||||
"document_url": "https://example.com/invoice.pdf"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`model` selects the OCR provider via a `provider/model` prefix; a bare model id (e.g.
|
||||
`mistral-ocr-latest`) resolves to its registered provider, and an omitted `model` defaults to
|
||||
Mistral (`mistral-ocr-latest`). Registered providers (`open-sse/config/ocrRegistry.ts`):
|
||||
|
||||
| Provider id | Model id | `model` value | Notes |
|
||||
| ----------------------------- | -------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `mistral` | `mistral-ocr-latest` | `mistral/mistral-ocr-latest` (or bare `mistral-ocr-latest`) | Synchronous — the response is returned directly from the single upstream call. |
|
||||
| `azure-document-intelligence` | `prebuilt-read` | `azure-document-intelligence/prebuilt-read` | Asynchronous upstream (`analyze` + poll) — see below. |
|
||||
|
||||
Both providers respond in the same Mistral-shaped body:
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": [{ "index": 0, "markdown": "# Extracted text..." }],
|
||||
"model": "mistral-ocr-latest",
|
||||
"usage_info": { "pages_processed": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
### Azure Document Intelligence poll flow
|
||||
|
||||
Azure Document Intelligence's `analyze` API is asynchronous: the initial request returns an
|
||||
`Operation-Location` header instead of a body, and the result must be polled for. The handler
|
||||
(`open-sse/handlers/ocr.ts`) polls that URL every second for up to 30 attempts, fails fast (does
|
||||
not keep polling) on a non-`ok` poll response or a `"failed"` status, and returns `504` if the
|
||||
operation is still running after the attempt budget is exhausted. The final Azure response is
|
||||
normalized into the same `pages`/`markdown` shape used by Mistral before being returned to the
|
||||
caller, so client code does not need to special-case the provider.
|
||||
|
||||
---
|
||||
|
||||
## List Models
|
||||
|
||||
```bash
|
||||
@@ -537,18 +489,18 @@ call**, so the reported `X-OmniRoute-Response-Latency` is near-zero
|
||||
(benchmarking, p50/p99 monitoring) should check the
|
||||
`X-OmniRoute-Cache-Latency` response header:
|
||||
|
||||
| Value | Meaning |
|
||||
| ----------- | ------------------------------------------------------------- |
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `synthetic` | Response served from cache; latency is not real upstream time |
|
||||
| _(absent)_ | Response from real upstream call |
|
||||
| *(absent)* | Response from real upstream call |
|
||||
|
||||
### Per-key cache bypass
|
||||
|
||||
API keys can opt out of semantic cache reads via `cacheDefaultMode`:
|
||||
|
||||
| Value | Behavior |
|
||||
| -------- | ----------------------------------------------- |
|
||||
| `legacy` | Normal cache behavior (default) |
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `legacy` | Normal cache behavior (default) |
|
||||
| `bypass` | Skip cache lookup entirely; always hit upstream |
|
||||
|
||||
Set at key creation (`POST /api/keys`) or update (`PATCH /api/keys/[id]`):
|
||||
@@ -651,13 +603,13 @@ X-OmniRoute-No-Cache: true
|
||||
|
||||
### Monitoring
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ---------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/api/sessions` | GET | Active session tracking |
|
||||
| `/api/rate-limits` | GET | Per-account rate limits |
|
||||
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
|
||||
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
|
||||
| `/api/modality-bridge/stats` | GET | In-memory Modality Bridge telemetry — per-modality `bridged`/`cacheHits`/`failures`/`lastUsedAt` counters (reset on restart; management auth) |
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `/api/sessions` | GET | Active session tracking |
|
||||
| `/api/rate-limits` | GET | Per-account rate limits |
|
||||
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
|
||||
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
|
||||
| `/api/modality-bridge/stats` | GET | In-memory Modality Bridge telemetry — per-modality `bridged`/`cacheHits`/`failures`/`lastUsedAt` counters (reset on restart; management auth) |
|
||||
|
||||
### Backup & Export/Import
|
||||
|
||||
|
||||
@@ -13,11 +13,9 @@
|
||||
* derives membership from here instead of duplicating it by hand, so adding a
|
||||
* provider to a registry automatically surfaces it — no second edit, no drift.
|
||||
*
|
||||
* `imageToText` is additionally derived from `OCR_PROVIDERS` (see
|
||||
* `resolveProviderServiceKinds`): a provider registered in the OCR registry gets
|
||||
* `imageToText` for free, no manual `serviceKinds` edit needed. Kinds without any
|
||||
* backing registry (webSearch, webFetch, llm) are still declared explicitly via
|
||||
* `serviceKinds` on the provider entry; callers union declared + derived sources.
|
||||
* Kinds without a backing registry (imageToText, webSearch, webFetch, llm) are
|
||||
* still declared explicitly via `serviceKinds` on the provider entry; callers
|
||||
* union the two sources.
|
||||
*/
|
||||
import { AUDIO_TRANSCRIPTION_PROVIDERS, AUDIO_SPEECH_PROVIDERS } from "./audioRegistry.ts";
|
||||
import { VIDEO_PROVIDERS } from "./videoRegistry.ts";
|
||||
@@ -60,8 +58,7 @@ export function getRegistryMediaKinds(providerId: string): RegistryMediaKind[] {
|
||||
|
||||
/**
|
||||
* Full set of serviceKinds for a provider: the explicitly declared ones (llm,
|
||||
* web*, imageToText) unioned with the media kinds derived from the registries,
|
||||
* plus `imageToText` derived from the OCR registry when not already declared.
|
||||
* web*, imageToText) unioned with the media kinds derived from the registries.
|
||||
*/
|
||||
export function resolveProviderServiceKinds(
|
||||
providerId: string,
|
||||
@@ -69,8 +66,5 @@ export function resolveProviderServiceKinds(
|
||||
): string[] {
|
||||
const set = new Set<string>(declared ?? []);
|
||||
for (const kind of getRegistryMediaKinds(providerId)) set.add(kind);
|
||||
if (Object.prototype.hasOwnProperty.call(OCR_PROVIDERS, providerId)) {
|
||||
set.add("imageToText");
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface OcrProvider {
|
||||
authType: string;
|
||||
authHeader: string;
|
||||
models: OcrModel[];
|
||||
transformation?: OcrTransformation;
|
||||
}
|
||||
|
||||
export interface ParsedOcrModel {
|
||||
@@ -24,86 +23,6 @@ export interface ParsedOcrModel {
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface OcrResponseShape {
|
||||
pages: Array<{ index: number; markdown: string }>;
|
||||
model: string;
|
||||
usage_info?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OcrTransformation {
|
||||
buildRequest(args: {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
body: Record<string, unknown>;
|
||||
modelId: string;
|
||||
}): { url: string; init: RequestInit };
|
||||
parseResponse(raw: unknown): OcrResponseShape;
|
||||
/** Async providers (Azure DI): return the poll URL from the first response, else null. */
|
||||
pollUrl?(res: Response): string | null;
|
||||
}
|
||||
|
||||
export const MISTRAL_PASSTHROUGH: OcrTransformation = {
|
||||
buildRequest({ baseUrl, token, body, modelId }) {
|
||||
return {
|
||||
url: baseUrl,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ ...body, model: modelId }),
|
||||
},
|
||||
};
|
||||
},
|
||||
parseResponse(raw) {
|
||||
return raw as OcrResponseShape;
|
||||
},
|
||||
};
|
||||
|
||||
export function getOcrTransformation(providerId: string): OcrTransformation {
|
||||
return OCR_PROVIDERS[providerId]?.transformation ?? MISTRAL_PASSTHROUGH;
|
||||
}
|
||||
|
||||
const AZURE_DI_API_VERSION = "2024-11-30";
|
||||
|
||||
function azureDiSource(document: Record<string, unknown> | undefined): Record<string, string> {
|
||||
if (!document) return {};
|
||||
const url = String(document.document_url ?? document.image_url ?? "");
|
||||
if (url.startsWith("data:")) {
|
||||
const comma = url.indexOf(",");
|
||||
return { base64Source: comma >= 0 ? url.slice(comma + 1) : "" };
|
||||
}
|
||||
return url ? { urlSource: url } : {};
|
||||
}
|
||||
|
||||
export const AZURE_DI_TRANSFORMATION: OcrTransformation = {
|
||||
buildRequest({ baseUrl, token, body, modelId }) {
|
||||
const root = baseUrl.replace(/\/+$/, "");
|
||||
return {
|
||||
url: `${root}/documentintelligence/documentModels/${modelId}:analyze?api-version=${AZURE_DI_API_VERSION}&outputContentFormat=markdown`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Ocp-Apim-Subscription-Key": token },
|
||||
body: JSON.stringify(azureDiSource(body.document as Record<string, unknown>)),
|
||||
},
|
||||
};
|
||||
},
|
||||
pollUrl(res) {
|
||||
return res.headers.get("Operation-Location");
|
||||
},
|
||||
parseResponse(raw) {
|
||||
const r = raw as {
|
||||
analyzeResult?: { content?: string; pages?: unknown[] };
|
||||
};
|
||||
const pageCount = r.analyzeResult?.pages?.length ?? 1;
|
||||
// Azure returns the whole-document markdown in `content`; we mirror it into the
|
||||
// Mistral shape as a single aggregated "page" (index 0), preserving pageCount.
|
||||
return {
|
||||
pages: [{ index: 0, markdown: r.analyzeResult?.content ?? "" }],
|
||||
model: "prebuilt-read",
|
||||
usage_info: { pages_processed: pageCount },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const OCR_PROVIDERS: Record<string, OcrProvider> = {
|
||||
mistral: {
|
||||
id: "mistral",
|
||||
@@ -112,14 +31,6 @@ export const OCR_PROVIDERS: Record<string, OcrProvider> = {
|
||||
authHeader: "bearer",
|
||||
models: [{ id: "mistral-ocr-latest", name: "Mistral OCR" }],
|
||||
},
|
||||
"azure-document-intelligence": {
|
||||
id: "azure-document-intelligence",
|
||||
baseUrl: "",
|
||||
authType: "apikey",
|
||||
authHeader: "Ocp-Apim-Subscription-Key",
|
||||
models: [{ id: "prebuilt-read", name: "Azure Document Intelligence (Read)" }],
|
||||
transformation: AZURE_DI_TRANSFORMATION,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,15 @@ export type ProviderErrorRule = {
|
||||
|
||||
export type ProviderErrorRuleMatch = {
|
||||
reason: ConfiguredErrorReason;
|
||||
/** Default "provider" — lock the whole connection so other providers take over. */
|
||||
/**
|
||||
* Intended lock scope. NOTE: this field is currently INFORMATIONAL — no
|
||||
* consumer of `getProviderErrorRuleMatch` (checkFallbackError, combo.ts)
|
||||
* reads `scope` today; only `reason` and `cooldownMs` are consulted. The
|
||||
* actual lock scope applied at runtime is decided independently by each
|
||||
* call site (e.g. `hasPerModelQuota()` deciding model- vs connection-level
|
||||
* lockout). Honoring this field end-to-end is tracked as a follow-up —
|
||||
* see `docs/architecture/RESILIENCE_GUIDE.md` §7.
|
||||
*/
|
||||
scope: "model" | "provider" | "connection";
|
||||
/** Optional explicit cooldown; falls back to the existing per-reason defaults. */
|
||||
cooldownMs?: number;
|
||||
@@ -176,6 +184,61 @@ function buildOpenrouterRules(): ProviderErrorRule[] {
|
||||
];
|
||||
}
|
||||
|
||||
// ─── AgentRouter ────────────────────────────────────────────────────────────
|
||||
// agentrouter.org misstates temporary quota exhaustion as 403/400 with a
|
||||
// Chinese body. upstreamStatusRestatement.ts rewrites the status to 429
|
||||
// BEFORE classification, so rules here accept both the raw 403/400 and the
|
||||
// restated 429 (text is the real discriminator either way). In production,
|
||||
// the raw 403 path is what actually matters here: checkFallbackError's
|
||||
// apikey-category FORBIDDEN branch (~line 1699) returns EARLY for a plain
|
||||
// 403, before these rules are ever consulted — these rules fire on the
|
||||
// RESTATED 429 (chatCore's upstreamStatusRestatement hook runs first) via
|
||||
// resolveRuleMatchBody, which is the only path in checkFallbackError that
|
||||
// hands these rules the full error text instead of just {code, type}.
|
||||
// - "额度不足": account-wide temporary quota → quota_exhausted, scope
|
||||
// "connection" (mirror of the Opencode account-wide rationale above).
|
||||
// NOTE: `scope` on ProviderErrorRuleMatch is currently informational —
|
||||
// checkFallbackError/combo.ts only consume `reason` and `cooldownMs`, not
|
||||
// `scope`. For agentrouter specifically (passthroughModels: true →
|
||||
// hasPerModelQuota() is true), this quota_exhausted match actually
|
||||
// resolves to a PER-MODEL lockout (recordModelLockoutFailure), not a
|
||||
// connection-wide lock — other models on the same account keep being
|
||||
// tried by combo routing (each burning one call) until they lock out
|
||||
// individually. Honoring `scope` end-to-end is tracked as a follow-up.
|
||||
// - "无权访问模型": declares auth_error/scope "model" (intent: lock only the
|
||||
// model so the connection keeps serving the rest — Model Lockout tier).
|
||||
// This rule does NOT fire on the production path today: it only matches
|
||||
// `status === 403`, but checkFallbackError's apikey FORBIDDEN branch
|
||||
// returns early for a plain 403 before this rule is ever consulted (see
|
||||
// the note above). A live `无权访问模型` 403 is handled like the base
|
||||
// apikey-provider 403 today. Wiring this rule into that path is tracked
|
||||
// as a follow-up.
|
||||
function buildAgentrouterRules(): ProviderErrorRule[] {
|
||||
const AGENTROUTER_ERROR_STATUSES = new Set([400, 403, 429]);
|
||||
return [
|
||||
{
|
||||
id: "agentrouter-user-quota-exhausted",
|
||||
match: ({ status, body }) => {
|
||||
if (!AGENTROUTER_ERROR_STATUSES.has(status)) return null;
|
||||
const text = JSON.stringify(body ?? "").toLowerCase();
|
||||
if (!text.includes("额度不足")) return null;
|
||||
return { reason: "quota_exhausted", scope: "connection" };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agentrouter-model-access-denied",
|
||||
match: ({ status, body }) => {
|
||||
if (status !== 403) return null;
|
||||
const text = JSON.stringify(body ?? "").toLowerCase();
|
||||
if (!text.includes("无权访问模型")) return null;
|
||||
// 6h: effectively "until the operator fixes the key's model grants",
|
||||
// without being an unrecoverable terminal state.
|
||||
return { reason: "auth_error", scope: "model", cooldownMs: 6 * 60 * 60 * 1000 };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Global registry. Provider name → ordered list of rules (first match wins).
|
||||
* Add new providers here; the matcher in classifyError will pick them up
|
||||
@@ -189,8 +252,37 @@ export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
|
||||
["minimax-passthrough", buildMinimaxRules()],
|
||||
["cloudflare-ai", buildCloudflareAiRules()],
|
||||
["openrouter", buildOpenrouterRules()],
|
||||
["agentrouter", buildAgentrouterRules()],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Providers whose rules match on the FULL upstream error text.
|
||||
* checkFallbackError's rule lookup normally passes only the structured
|
||||
* error ({code, type} — message stripped by the combo callers), which is
|
||||
* enough for header/status/code rules but blind to body-text markers like
|
||||
* agentrouter's "额度不足". Providers in this set get the raw error text as
|
||||
* the match body instead. EXCLUSIVE allowlist by owner decision (2026-08-13):
|
||||
* adding a provider here is an explicit opt-in — the default path for every
|
||||
* other provider must remain byte-for-byte unchanged.
|
||||
*/
|
||||
const FULL_TEXT_RULE_PROVIDERS = new Set(["agentrouter"]);
|
||||
|
||||
/**
|
||||
* Resolve the body handed to getProviderErrorRuleMatch inside
|
||||
* checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS,
|
||||
* the structured error for everyone else.
|
||||
*/
|
||||
export function resolveRuleMatchBody(
|
||||
provider: string | null | undefined,
|
||||
structuredError: unknown,
|
||||
errorText: string | null | undefined
|
||||
): unknown {
|
||||
if (provider && FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) && errorText) {
|
||||
return errorText;
|
||||
}
|
||||
return structuredError ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first matching rule for a provider, or null if none match.
|
||||
* Callers use this to (a) classify the reason and (b) decide whether to
|
||||
|
||||
129
open-sse/config/upstreamStatusRestatement.ts
Normal file
129
open-sse/config/upstreamStatusRestatement.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Upstream status restatement — registry of gateways that MISSTATE temporary
|
||||
* quota exhaustion as a non-retryable HTTP status.
|
||||
*
|
||||
* agentrouter.org signals "user quota exhausted" with 403 (sometimes 400) and
|
||||
* a Chinese body ("用户额度不足") instead of the standard 429. Clients like
|
||||
* Claude Code treat 403 as permanent and abort the whole session, and our own
|
||||
* fallback engine classifies it as AUTH_ERROR instead of a quota event.
|
||||
*
|
||||
* applyStatusRestatement() is called from exactly ONE place — the
|
||||
* `providerFailure:` block in open-sse/handlers/chatCore.ts, right after
|
||||
* parseUpstreamError() parses an upstream response with an error HTTP status
|
||||
* (!providerResponse.ok), and before any classification runs — so every
|
||||
* downstream consumer (checkFallbackError, combo aggregation, the client
|
||||
* response) sees the corrected status. Errors embedded inside a 200 SSE
|
||||
* stream follow a separate, later stream-parsing path and are NOT covered by
|
||||
* this hook today (known limitation; not yet needed for agentrouter's
|
||||
* misstatus, which surfaces as an error HTTP status). 429 is
|
||||
* Retry-After-eligible in
|
||||
* open-sse/services/combo/unavailableRetryGate.ts, so the client also gets a
|
||||
* retry window instead of a dead 403.
|
||||
*
|
||||
* Adding a future gateway with the same defect = register ONE rule array
|
||||
* below (and, for cooldown-scope refinement, one entry in
|
||||
* providerErrorRules.ts). No pipeline changes.
|
||||
*
|
||||
* Marker discipline: keep textMarkers provider-specific (the Chinese strings
|
||||
* are upstream error literals, not UI copy). Generic English phrases like
|
||||
* "insufficient_quota" are in CREDITS_EXHAUSTED_SIGNALS
|
||||
* (accountFallback.ts) and would flip the connection into a terminal
|
||||
* credits_exhausted state — never use them as markers here.
|
||||
*
|
||||
* Accepted trade-off: matching only on response body text means a
|
||||
* legitimate 400 whose body ECHOES user-supplied content containing a
|
||||
* marker (e.g. a prompt that itself contains "额度不足") would be restated to
|
||||
* 429 and lose the combo's 400 stop-guard. This is treated as an acceptable
|
||||
* risk because these markers are rare outside a genuine upstream error;
|
||||
* keeping markers short, provider-specific, and non-generic (as above)
|
||||
* minimizes false-positive restatement.
|
||||
*/
|
||||
|
||||
export type UpstreamStatusRestatementRule = {
|
||||
id: string;
|
||||
fromStatuses: ReadonlySet<number>;
|
||||
toStatus: number;
|
||||
/** Lowercase markers matched against lowercased `message` + JSON(body). Any hit → restate. */
|
||||
textMarkers: readonly string[];
|
||||
/** Lowercase markers that VETO the rule even when textMarkers hit (permanent errors). */
|
||||
excludeMarkers?: readonly string[];
|
||||
/** Synthetic Retry-After used ONLY when the upstream provided none. */
|
||||
defaultRetryAfterMs?: number;
|
||||
};
|
||||
|
||||
export type StatusRestatementInput = {
|
||||
provider: string | null | undefined;
|
||||
status: number;
|
||||
message: string | null | undefined;
|
||||
body?: unknown;
|
||||
retryAfterMs?: number | null;
|
||||
};
|
||||
|
||||
export type StatusRestatementResult = {
|
||||
status: number;
|
||||
retryAfterMs: number | null;
|
||||
ruleId: string | null;
|
||||
fromStatus: number;
|
||||
};
|
||||
|
||||
// ─── agentrouter ────────────────────────────────────────────────────────────
|
||||
// Observed misstatus (ClaudeShield field reports + upstream behavior):
|
||||
// 403 "用户额度不足" / "额度不足" → temporary user-quota exhaustion → 429
|
||||
// 400 variants carrying the same quota text → 429
|
||||
// 403 "无权访问模型" (no access to this model) → genuinely permanent, NEVER
|
||||
// restated — it must keep flowing as 403 so nothing retries it forever.
|
||||
const AGENTROUTER_RULES: UpstreamStatusRestatementRule[] = [
|
||||
{
|
||||
id: "agentrouter-quota-misstatus",
|
||||
fromStatuses: new Set([403, 400]),
|
||||
toStatus: 429,
|
||||
textMarkers: ["额度不足"],
|
||||
excludeMarkers: ["无权访问"],
|
||||
defaultRetryAfterMs: 60_000,
|
||||
},
|
||||
];
|
||||
|
||||
/** Provider id (lowercase) → ordered rules; first match wins. */
|
||||
export const statusRestatementRegistry = new Map<string, UpstreamStatusRestatementRule[]>([
|
||||
["agentrouter", AGENTROUTER_RULES],
|
||||
]);
|
||||
|
||||
function stringifyBody(body: unknown): string {
|
||||
if (body === null || body === undefined) return "";
|
||||
if (typeof body === "string") return body;
|
||||
try {
|
||||
return JSON.stringify(body);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function applyStatusRestatement(input: StatusRestatementInput): StatusRestatementResult {
|
||||
const passthrough: StatusRestatementResult = {
|
||||
status: input.status,
|
||||
retryAfterMs: input.retryAfterMs ?? null,
|
||||
ruleId: null,
|
||||
fromStatus: input.status,
|
||||
};
|
||||
if (!input.provider) return passthrough;
|
||||
const rules = statusRestatementRegistry.get(input.provider.toLowerCase());
|
||||
if (!rules) return passthrough;
|
||||
|
||||
const haystack = `${input.message ?? ""} ${stringifyBody(input.body)}`.toLowerCase();
|
||||
if (!haystack.trim()) return passthrough;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.fromStatuses.has(input.status)) continue;
|
||||
if (!rule.textMarkers.some((marker) => haystack.includes(marker))) continue;
|
||||
if (rule.excludeMarkers?.some((marker) => haystack.includes(marker))) continue;
|
||||
const upstreamRetryAfterMs =
|
||||
typeof input.retryAfterMs === "number" && input.retryAfterMs > 0 ? input.retryAfterMs : null;
|
||||
return {
|
||||
status: rule.toStatus,
|
||||
retryAfterMs: upstreamRetryAfterMs ?? rule.defaultRetryAfterMs ?? null,
|
||||
ruleId: rule.id,
|
||||
fromStatus: input.status,
|
||||
};
|
||||
}
|
||||
return passthrough;
|
||||
}
|
||||
@@ -190,6 +190,7 @@ import {
|
||||
DEFAULT_MAX_TOKENS,
|
||||
STREAM_DISCONNECT_GRACE_PERIOD_MS,
|
||||
} from "../config/constants.ts";
|
||||
import { applyStatusRestatement } from "../config/upstreamStatusRestatement.ts";
|
||||
import { createRecoverableStream, makeContinuationBody } from "../services/streamRecovery.ts";
|
||||
import {
|
||||
resolveResilienceSettings,
|
||||
@@ -3667,6 +3668,27 @@ export async function handleChatCore({
|
||||
upstreamErrorType = details.errorType as string | undefined;
|
||||
}
|
||||
|
||||
// Gateways like agentrouter misstate temporary quota exhaustion as 403/400,
|
||||
// which downstream classification treats as AUTH_ERROR and clients like
|
||||
// Claude Code treat as permanent. Restate to 429 (+ synthetic Retry-After)
|
||||
// BEFORE any classification so both the fallback engine and the surfaced
|
||||
// client status see a retryable error. Registry-scoped per provider.
|
||||
const restatement = applyStatusRestatement({
|
||||
provider,
|
||||
status: statusCode,
|
||||
message,
|
||||
body: upstreamErrorBody,
|
||||
retryAfterMs,
|
||||
});
|
||||
if (restatement.ruleId) {
|
||||
statusCode = restatement.status;
|
||||
retryAfterMs = restatement.retryAfterMs;
|
||||
log?.info?.(
|
||||
"STATUS_RESTATE",
|
||||
`${provider} ${restatement.fromStatus}→${statusCode} (${restatement.ruleId})`
|
||||
);
|
||||
}
|
||||
|
||||
const signatureRecovery = await recoverAnthropicThinkingSignature({
|
||||
provider,
|
||||
statusCode,
|
||||
|
||||
@@ -5,43 +5,21 @@ import { CORS_HEADERS } from "../utils/cors.ts";
|
||||
* Handles POST /v1/ocr (Mistral OCR API format).
|
||||
*/
|
||||
|
||||
import {
|
||||
getOcrProvider,
|
||||
getOcrTransformation,
|
||||
parseOcrModel,
|
||||
OCR_PROVIDERS,
|
||||
} from "../config/ocrRegistry.ts";
|
||||
import { getOcrProvider, parseOcrModel } from "../config/ocrRegistry.ts";
|
||||
import { errorResponse } from "../utils/error.ts";
|
||||
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
|
||||
const OCR_POLL_MAX_ATTEMPTS = 30;
|
||||
const OCR_POLL_INTERVAL_MS = 1000;
|
||||
|
||||
const defaultSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Handle OCR request
|
||||
*
|
||||
* Dispatches to the per-provider transformation (see `open-sse/config/ocrRegistry.ts`)
|
||||
* to build the upstream request, then (for async providers like Azure Document
|
||||
* Intelligence) polls the returned operation URL until it succeeds or fails,
|
||||
* before normalizing the response into the Mistral OCR shape.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Object} options.body - JSON body { model, document }
|
||||
* @param {Object} options.credentials - Provider credentials { apiKey, accessToken, baseUrl }
|
||||
* @param {Function} [options.fetchImpl] - DI hook for tests; defaults to global fetch
|
||||
* @param {Function} [options.sleepImpl] - DI hook for tests; defaults to a real setTimeout-based sleep
|
||||
* @param {Object} options.credentials - Provider credentials { apiKey }
|
||||
* @returns {Response}
|
||||
*/
|
||||
/** @returns {Promise<unknown>} */
|
||||
export async function handleOcr({
|
||||
body,
|
||||
credentials,
|
||||
fetchImpl = fetch,
|
||||
sleepImpl = defaultSleep,
|
||||
}) {
|
||||
export async function handleOcr({ body, credentials }) {
|
||||
const startTime = Date.now();
|
||||
if (!body.document) {
|
||||
return errorResponse(400, "document is required");
|
||||
@@ -53,10 +31,7 @@ export async function handleOcr({
|
||||
const providerConfig = providerId ? getOcrProvider(providerId) : null;
|
||||
|
||||
if (!providerConfig) {
|
||||
return errorResponse(
|
||||
400,
|
||||
`No OCR provider found for model "${model}". Available: ${Object.keys(OCR_PROVIDERS).join(", ")}`
|
||||
);
|
||||
return errorResponse(400, `No OCR provider found for model "${model}". Available: mistral`);
|
||||
}
|
||||
|
||||
const token = credentials?.apiKey || credentials?.accessToken;
|
||||
@@ -64,15 +39,18 @@ export async function handleOcr({
|
||||
return errorResponse(401, `No credentials for OCR provider: ${providerId}`);
|
||||
}
|
||||
|
||||
const baseUrl = credentials?.baseUrl || providerConfig.baseUrl;
|
||||
if (!baseUrl) {
|
||||
return errorResponse(400, `No base URL configured for OCR provider: ${providerId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const transformation = getOcrTransformation(providerId);
|
||||
const { url, init } = transformation.buildRequest({ baseUrl, token, body, modelId });
|
||||
const res = await fetchImpl(url, init);
|
||||
const res = await fetch(providerConfig.baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
model: modelId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
@@ -85,17 +63,7 @@ export async function handleOcr({
|
||||
});
|
||||
}
|
||||
|
||||
const pollUrl = transformation.pollUrl?.(res) ?? null;
|
||||
let data: unknown;
|
||||
if (pollUrl) {
|
||||
const authHeader = buildAuthHeader(providerConfig.authHeader, token);
|
||||
data = await pollOcrOperation({ pollUrl, authHeader, fetchImpl, sleepImpl });
|
||||
if (data instanceof Response) return data;
|
||||
} else {
|
||||
data = await res.json();
|
||||
}
|
||||
|
||||
const parsed = transformation.parseResponse(data);
|
||||
const data = await res.json();
|
||||
const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });
|
||||
attachOmniRouteMetaHeaders(headers, {
|
||||
provider: providerId,
|
||||
@@ -104,48 +72,8 @@ export async function handleOcr({
|
||||
latencyMs: Date.now() - startTime,
|
||||
requestId: generateRequestId(),
|
||||
});
|
||||
return new Response(JSON.stringify(parsed), { status: 200, headers });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers });
|
||||
} catch (err) {
|
||||
console.error("[OCR]", err);
|
||||
return errorResponse(500, "OCR request failed");
|
||||
return errorResponse(500, `OCR request failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the same auth header used for the initial upstream request, so the
|
||||
* poll GET (e.g. Azure Document Intelligence's Operation-Location) authenticates
|
||||
* identically.
|
||||
*/
|
||||
function buildAuthHeader(authHeader: string, token: string): Record<string, string> {
|
||||
if (authHeader === "bearer") {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return { [authHeader]: token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll an async OCR operation (Azure Document Intelligence) until it succeeds or fails.
|
||||
*
|
||||
* @returns {Promise<unknown|Response>} the parsed JSON body on success, or an error Response
|
||||
*/
|
||||
async function pollOcrOperation({ pollUrl, authHeader, fetchImpl, sleepImpl }) {
|
||||
for (let attempt = 0; attempt < OCR_POLL_MAX_ATTEMPTS; attempt++) {
|
||||
await sleepImpl(OCR_POLL_INTERVAL_MS);
|
||||
const pollRes = await fetchImpl(pollUrl, {
|
||||
method: "GET",
|
||||
headers: authHeader,
|
||||
});
|
||||
if (!pollRes.ok) {
|
||||
console.error("[OCR] poll error", pollRes.status);
|
||||
return errorResponse(502, "OCR analysis failed");
|
||||
}
|
||||
const json = await pollRes.json();
|
||||
if (json.status === "succeeded") {
|
||||
return json;
|
||||
}
|
||||
if (json.status === "failed") {
|
||||
return errorResponse(502, "OCR analysis failed");
|
||||
}
|
||||
}
|
||||
return errorResponse(504, "OCR analysis timed out");
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
serviceSupervisorCooldown,
|
||||
isNimFunctionDegraded,
|
||||
} from "../config/errorConfig.ts";
|
||||
import { getProviderErrorRuleMatch } from "../config/providerErrorRules.ts";
|
||||
import { getProviderErrorRuleMatch, resolveRuleMatchBody } from "../config/providerErrorRules.ts";
|
||||
import * as rot from "./rotationConfig.ts";
|
||||
import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts";
|
||||
import {
|
||||
@@ -1743,7 +1743,12 @@ export function checkFallbackError(
|
||||
// specific configured reasons (e.g. 503 → SERVER_ERROR would be
|
||||
// shadowed by 503 → MODEL_CAPACITY).
|
||||
const providerMatch = provider
|
||||
? getProviderErrorRuleMatch(provider, status, headers, structuredError ?? null)
|
||||
? getProviderErrorRuleMatch(
|
||||
provider,
|
||||
status,
|
||||
headers,
|
||||
resolveRuleMatchBody(provider, structuredError ?? null, errorStr)
|
||||
)
|
||||
: null;
|
||||
const reason = providerMatch
|
||||
? providerMatch.reason
|
||||
@@ -1776,7 +1781,12 @@ export function checkFallbackError(
|
||||
// generic zero-cooldown default. Mirror the backoff branch above so
|
||||
// provider rules win on cooldown/reason regardless of `backoff`.
|
||||
const providerMatch = provider
|
||||
? getProviderErrorRuleMatch(provider, status, headers, structuredError ?? null)
|
||||
? getProviderErrorRuleMatch(
|
||||
provider,
|
||||
status,
|
||||
headers,
|
||||
resolveRuleMatchBody(provider, structuredError ?? null, errorStr)
|
||||
)
|
||||
: null;
|
||||
const cooldownMs = providerMatch?.cooldownMs ?? configuredRule.cooldownMs ?? 0;
|
||||
return {
|
||||
|
||||
@@ -95,6 +95,30 @@ export const OUTPUT_STYLE_CATALOG: Record<string, OutputStyle> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
// i-have-adhd (action-first output) — integrated into the output-style registry
|
||||
// so it rides the existing production injector, like ponytail.
|
||||
// Source: https://github.com/ayghri/i-have-adhd (MIT). The upstream skill's 10
|
||||
// ADHD-friendly rules, adapted for proxy injection: agent-harness-specific rules
|
||||
// (restate plan state, time estimates) reworded as conditionals so they hold for
|
||||
// plain chat clients too.
|
||||
"i-have-adhd": {
|
||||
id: "i-have-adhd",
|
||||
label: "I have ADHD (action-first)",
|
||||
description:
|
||||
"Action-first output: next action leads, steps numbered, one concrete next step, no preamble.",
|
||||
levels: {
|
||||
lite: `# I have ADHD (lite)\nLead with the action: command, path, or snippet first, prose after. Number multi-step work; each step one bounded action. End with ONE concrete next step. No preamble, no recap, no closing pleasantries. ${SHARED_BOUNDARIES}`,
|
||||
full: `# I have ADHD — action-first output\n\nThe reader has ADHD. Shape output so an ADHD brain can act on it:\n1. Lead with the next action — command, path, or snippet first; context after, if at all.\n2. Number multi-step work; each step is one bounded action; use the fewest steps that work.\n3. End with ONE concrete next step doable in under two minutes.\n4. Suppress tangents: finish the first issue, offer the second as a separate question.\n5. In multi-turn work, restate where things stand ("step 3 of 5 done") — the reader cannot hold state between messages.\n6. When human effort is involved, estimate it in concrete units (minutes, an afternoon), never "some work".\n7. Make wins visible: state what now works and how to try it.\n8. Errors matter-of-fact: cause and fix; never "Uh oh".\n9. Cap lists at 5 items; split into "do now" vs "later" beyond that.\n10. No preamble, no recap, no closers ("Hope this helps").\nExceptions: an explicit "explain" request gets a full body (still no preamble/closer); destructive actions get confirmation first; real ambiguity gets one short clarifying question. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# I have ADHD (ultra)\nAction first: command/path/snippet, then prose if needed. Numbered bounded steps, fewest that work. One <2-min next step at the end. No tangents — separate question. Multi-turn: restate state. Human effort: concrete time units. Wins visible. Errors: cause + fix. Lists ≤5. Zero preamble/recap/closers. Explain-requests get full body; destructive actions get confirmation; real ambiguity gets one question. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
i18n: {
|
||||
"pt-BR": {
|
||||
lite: `# Eu tenho TDAH (lite)\nComece pela ação: comando, path ou snippet primeiro, prosa depois. Numere trabalho multi-passo; cada passo é uma ação delimitada. Termine com UMA próxima ação concreta. Sem preâmbulo, sem recap, sem despedidas. ${SHARED_BOUNDARIES}`,
|
||||
full: `# Eu tenho TDAH — saída action-first\n\nO leitor tem TDAH. Molde a saída para que um cérebro TDAH consiga agir sobre ela:\n1. Comece pela próxima ação — comando, path ou snippet primeiro; contexto depois, se necessário.\n2. Numere trabalho multi-passo; cada passo é uma ação delimitada; use o menor número de passos que funcione.\n3. Termine com UMA próxima ação concreta executável em menos de dois minutos.\n4. Suprima tangentes: termine a primeira questão, ofereça a segunda como pergunta separada.\n5. Em trabalho multi-turno, reafirme onde as coisas estão ("passo 3 de 5 feito") — o leitor não guarda estado entre mensagens.\n6. Quando houver esforço humano, estime em unidades concretas (minutos, uma tarde), nunca "um pouco de trabalho".\n7. Torne vitórias visíveis: diga o que funciona agora e como testar.\n8. Erros de forma direta: causa e fix; nunca "Opa!".\n9. Listas com no máximo 5 itens; acima disso, divida em "agora" vs "depois".\n10. Sem preâmbulo, sem recap, sem despedidas ("Espero ter ajudado").\nExceções: pedido explícito de "explique" recebe corpo completo (ainda sem preâmbulo/despedida); ações destrutivas recebem confirmação antes; ambiguidade real recebe uma pergunta curta de esclarecimento. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Eu tenho TDAH (ultra)\nAção primeiro: comando/path/snippet, prosa depois se precisar. Passos numerados e delimitados, o mínimo que funcione. UMA próxima ação <2 min no fim. Sem tangentes — pergunta separada. Multi-turno: reafirme o estado. Esforço humano: unidades concretas de tempo. Vitórias visíveis. Erros: causa + fix. Listas ≤5. Zero preâmbulo/recap/despedidas. "Explique" recebe corpo completo; ação destrutiva recebe confirmação; ambiguidade real recebe uma pergunta. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
"terse-cjk": {
|
||||
id: "terse-cjk",
|
||||
label: "Terse CJK (文言)",
|
||||
|
||||
@@ -15,25 +15,6 @@ import {
|
||||
rateLimitedProviderResponse,
|
||||
} from "@/app/api/v1/_shared/rateLimit";
|
||||
|
||||
/**
|
||||
* Custom-endpoint providers (e.g. azure-document-intelligence) store the
|
||||
* connection's resource endpoint under providerSpecificData.baseUrl, not as
|
||||
* a top-level credentials field — mirror the convention used across
|
||||
* src/lib/providers/validation/* (see e.g. urlHelpers.ts). handleOcr reads
|
||||
* credentials.baseUrl, so surface it here. An existing top-level baseUrl
|
||||
* always wins (kept for tests/callers that pass it directly).
|
||||
*/
|
||||
export function resolveOcrCredentials<
|
||||
T extends { baseUrl?: string; providerSpecificData?: Record<string, unknown> },
|
||||
>(credentials: T): T {
|
||||
if (credentials?.baseUrl) return credentials;
|
||||
const providerSpecificBaseUrl = credentials?.providerSpecificData?.baseUrl;
|
||||
if (typeof providerSpecificBaseUrl === "string" && providerSpecificBaseUrl.trim()) {
|
||||
return { ...credentials, baseUrl: providerSpecificBaseUrl };
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
@@ -85,9 +66,7 @@ async function postHandler(request, context) {
|
||||
return rateLimitedProviderResponse(resolvedProvider, credentials);
|
||||
}
|
||||
|
||||
const ocrCredentials = resolveOcrCredentials(credentials);
|
||||
|
||||
const response = await handleOcr({ body: { ...body, model }, credentials: ocrCredentials });
|
||||
const response = await handleOcr({ body: { ...body, model }, credentials });
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
}
|
||||
|
||||
@@ -932,10 +932,6 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
|
||||
"No free tier as of 2026 — Chutes moved to pay-as-you-go (free Early Access ended 2026-03).",
|
||||
authHint: "Bearer API key for the Chutes OpenAI-compatible gateway.",
|
||||
passthroughModels: true,
|
||||
// dots.ocr (rednote-hilab/dots.ocr) is served via Chutes discovery — no static
|
||||
// model entry needed (passthroughModels). Declare imageToText alongside llm
|
||||
// (declaring serviceKinds means "llm" must be explicit too, see #10275).
|
||||
serviceKinds: ["llm", "imageToText"],
|
||||
},
|
||||
// Factory AI ("Factory Droids") subscription gateway — the same backend the
|
||||
// local `droid` CLI shells into, exposed here as an OpenAI-compatible HTTP
|
||||
|
||||
@@ -9,10 +9,7 @@ import { withSettingsFallback } from "./cliInstallFallback";
|
||||
import { GROK_BUILD_RUNTIME_ENTRY, AMP_RUNTIME_ENTRY } from "./cliRuntimeGrokBuild";
|
||||
import { isLocationTrusted, findKnownPathMatch } from "./cliRuntimeKnownPath";
|
||||
import { buildHealthcheckPath } from "./cliRuntimeHealthcheckPath";
|
||||
import {
|
||||
resolveOpencodeConfigDir as resolveOpenCodeConfigDir,
|
||||
resolveOpencodeConfigPath as resolveOpenCodeConfigPath,
|
||||
} from "./opencodeConfigPath";
|
||||
import { resolveOpencodeConfigPath as resolveOpenCodeConfigPath } from "./opencodeConfigPath";
|
||||
const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]);
|
||||
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
|
||||
|
||||
@@ -973,19 +970,6 @@ export const getCliConfigHome = () => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const resolveOpencodeConfigDir = (
|
||||
_platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homeDir = os.homedir()
|
||||
) => {
|
||||
// #3330: OpenCode reads its config from XDG `~/.config/opencode/` on ALL
|
||||
// platforms — including Windows, where it uses `%USERPROFILE%\.config`, NOT
|
||||
// `%APPDATA%`. Writing to %APPDATA% on Windows put the file where OpenCode
|
||||
// never looks, so dashboard-saved config silently had no effect. `_platform`
|
||||
// is kept in the signature for call-site/test compatibility.
|
||||
return path.dirname(resolveOpenCodeConfigDir(env, homeDir));
|
||||
};
|
||||
|
||||
export const resolveOpencodeConfigPath = (
|
||||
_platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"tests/unit/adaptive-admission-route-matrix.test.ts",
|
||||
"tests/unit/adaptive-admission-runtime.test.ts",
|
||||
"tests/unit/adobe-firefly.test.ts",
|
||||
"tests/unit/agentrouter-error-rules.test.ts",
|
||||
"tests/unit/alibaba-free-tier-exhaustion.test.ts",
|
||||
"tests/unit/anthropic-thinking-signature-recovery.test.ts",
|
||||
"tests/unit/antigravity-429-quota-tdd.test.ts",
|
||||
|
||||
141
tests/unit/agentrouter-error-rules.test.ts
Normal file
141
tests/unit/agentrouter-error-rules.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* agentrouter.org quota model, as declared by the `providerErrorRules.ts`
|
||||
* classification layer:
|
||||
* - "额度不足" (quota insufficient) is ACCOUNT-wide and temporary → the rule
|
||||
* declares scope "connection".
|
||||
* - "无权访问模型" (no access to this model) is permanent PER MODEL → the rule
|
||||
* declares scope "model".
|
||||
* Status matching accepts both the raw upstream 403 AND the restated 429
|
||||
* (upstreamStatusRestatement.ts rewrites 403→429 before classification).
|
||||
*
|
||||
* IMPORTANT — `scope` above is what the rule DECLARES, not what production
|
||||
* enforces: `ProviderErrorRuleMatch.scope` is not consumed by
|
||||
* checkFallbackError/combo.ts today (only `reason`/`cooldownMs` are). For
|
||||
* agentrouter (passthroughModels: true → hasPerModelQuota() true), the
|
||||
* quota_exhausted match actually resolves to a PER-MODEL lockout in
|
||||
* production, not a connection-wide lock — other models on the same account
|
||||
* keep being tried by combo routing until they lock out individually. And
|
||||
* the "无权访问模型" rule never reaches production traffic at all today: it
|
||||
* only matches raw `status === 403`, but checkFallbackError's apikey
|
||||
* FORBIDDEN branch returns early for a plain 403 before any provider rule is
|
||||
* consulted (see A7). See `docs/architecture/RESILIENCE_GUIDE.md` §7 for the
|
||||
* full writeup and the tracked follow-up to honor `scope`.
|
||||
*/
|
||||
|
||||
const { providerRuleRegistry, getProviderErrorRuleMatch } = await import(
|
||||
"../../open-sse/config/providerErrorRules.ts"
|
||||
);
|
||||
const { classifyError, checkFallbackError } = await import(
|
||||
"../../open-sse/services/accountFallback.ts"
|
||||
);
|
||||
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
|
||||
|
||||
test("A1: agentrouter is registered in providerRuleRegistry", () => {
|
||||
const rules = providerRuleRegistry.get("agentrouter");
|
||||
assert.ok(rules && rules.length > 0);
|
||||
});
|
||||
|
||||
test("A2: quota body → quota_exhausted scope connection (restated 429)", () => {
|
||||
const match = getProviderErrorRuleMatch("agentrouter", 429, {}, {
|
||||
error: { message: "用户额度不足,请充值" },
|
||||
});
|
||||
assert.ok(match, "quota body must match");
|
||||
assert.equal(match.reason, "quota_exhausted");
|
||||
assert.equal(match.scope, "connection");
|
||||
});
|
||||
|
||||
test("A3: quota body also matches the raw (pre-restatement) 403", () => {
|
||||
const match = getProviderErrorRuleMatch("agentrouter", 403, {}, "用户额度不足");
|
||||
assert.ok(match);
|
||||
assert.equal(match.reason, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("A4: 无权访问模型 → auth_error scope model, at the RULE layer only (getProviderErrorRuleMatch directly) — this rule never receives production traffic (see A7): checkFallbackError's apikey FORBIDDEN branch returns early for a plain 403 before reaching this rule", () => {
|
||||
const match = getProviderErrorRuleMatch("agentrouter", 403, {}, {
|
||||
error: { message: "无权访问模型 claude-sonnet-4" },
|
||||
});
|
||||
assert.ok(match);
|
||||
assert.equal(match.reason, "auth_error");
|
||||
assert.equal(match.scope, "model");
|
||||
});
|
||||
|
||||
test("A5: classifyError layer guard — quota text wins over the 403→AUTH_ERROR status fallback (classifyError itself has no production caller today; the production guard is A6/checkFallbackError)", () => {
|
||||
const reason = classifyError(403, "用户额度不足", {
|
||||
provider: "agentrouter",
|
||||
headers: {},
|
||||
body: { error: { message: "用户额度不足" } },
|
||||
});
|
||||
assert.equal(reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("A6: guard — restated quota error is retryable, never terminal, and now actually classified as quota_exhausted", () => {
|
||||
// Status 429 (post-restatement) reaches checkFallbackError's provider-rule
|
||||
// lookup. resolveRuleMatchBody() hands agentrouter the full error text
|
||||
// (instead of just the stripped {code, type} structuredError every other
|
||||
// provider gets), so the "额度不足" rule actually fires here — this is the
|
||||
// production path the restatement hook (Task 2) feeds into.
|
||||
const result = checkFallbackError(429, "用户额度不足", 0, null, "agentrouter", null);
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, "quota_exhausted");
|
||||
assert.ok(!result.permanent, "quota misstatus must never be permanent");
|
||||
assert.ok(!result.creditsExhausted, "must not trip CREDITS_EXHAUSTED_SIGNALS");
|
||||
assert.ok(result.cooldownMs > 0, "must carry a real cooldown");
|
||||
});
|
||||
|
||||
test("A7: guard — raw 403 quota (hook bypassed) is still not account-deactivation", () => {
|
||||
// A raw (pre-restatement) 403 never actually reaches the agentrouter provider
|
||||
// rules in production: checkFallbackError's apikey-category FORBIDDEN branch
|
||||
// (status === 403 && getProviderCategory(provider) === "apikey") returns
|
||||
// EARLY via resolveApiKeyForbiddenFallback before the provider-rule lookup
|
||||
// is ever consulted. In the real pipeline, chatCore's upstreamStatusRestatement
|
||||
// hook (Task 2) already converts 403→429 before checkFallbackError ever sees
|
||||
// it, so this early-return path is what a hook-bypassed raw 403 hits — and it
|
||||
// must still not be misclassified as permanent account deactivation.
|
||||
const result = checkFallbackError(403, "用户额度不足", 0, null, "agentrouter", null);
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.ok(!result.permanent);
|
||||
});
|
||||
|
||||
test("A8: plain agentrouter 403 (no quota text) keeps the default apikey auth path", () => {
|
||||
const match = getProviderErrorRuleMatch("agentrouter", 403, {}, "Invalid API key");
|
||||
assert.equal(match, null);
|
||||
});
|
||||
|
||||
test("A9: resolveRuleMatchBody hands full text ONLY to allowlisted providers", async () => {
|
||||
const { resolveRuleMatchBody } = await import(
|
||||
"../../open-sse/config/providerErrorRules.ts"
|
||||
);
|
||||
const structured = { code: "rate_limited", type: "requests" };
|
||||
assert.equal(resolveRuleMatchBody("agentrouter", structured, "用户额度不足"), "用户额度不足");
|
||||
assert.equal(resolveRuleMatchBody("opencode", structured, "monthly usage limit reached"), structured);
|
||||
assert.equal(resolveRuleMatchBody("openrouter", null, "some error text"), null);
|
||||
assert.equal(resolveRuleMatchBody("agentrouter", structured, ""), structured);
|
||||
});
|
||||
|
||||
test("A10: other providers' checkFallbackError behavior is unchanged (exclusivity)", () => {
|
||||
// opencode's body-text rule ("organization_quota_exceeded") must still NOT
|
||||
// fire through checkFallbackError — the allowlist is agentrouter-only, so
|
||||
// opencode keeps getting only the stripped structuredError as the match
|
||||
// body (null here, since no structuredError arg is passed), same as before
|
||||
// this fix. Baseline captured on the pre-fix code with this exact input:
|
||||
// { shouldFallback: true, cooldownMs: 3000, baseCooldownMs: 3000,
|
||||
// newBackoffLevel: 1, usedUpstreamRetryHint: false,
|
||||
// reason: "rate_limit_exceeded" }
|
||||
// i.e. it falls through to the generic 429 configured rule, NOT the
|
||||
// opencode-quota-exhausted-body provider rule — asserting `reason` here is
|
||||
// exactly what proves the allowlist didn't leak to opencode.
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
'{"error":{"message":"organization_quota_exceeded"}}',
|
||||
0,
|
||||
null,
|
||||
"opencode",
|
||||
null
|
||||
);
|
||||
assert.ok(result.shouldFallback);
|
||||
assert.equal(result.reason, "rate_limit_exceeded");
|
||||
assert.equal(result.cooldownMs, 3000);
|
||||
});
|
||||
76
tests/unit/compression/i-have-adhd-catalog.test.ts
Normal file
76
tests/unit/compression/i-have-adhd-catalog.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Tests for the i-have-adhd output style — action-first prompt injection.
|
||||
*
|
||||
* Verifies:
|
||||
* - i-have-adhd is registered with lite/full/ultra levels
|
||||
* - i18n map exists for pt-BR with all three levels
|
||||
* - Each level (en and pt-BR) contains the SHARED_BOUNDARIES suffix
|
||||
* - Core concepts present: action-first, numbered steps, no preamble
|
||||
* - No locale gate (style valid under every language)
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
OUTPUT_STYLE_CATALOG,
|
||||
outputStyleMeta,
|
||||
} from "../../../open-sse/services/compression/outputStyles/catalog.ts";
|
||||
|
||||
const ADHD = OUTPUT_STYLE_CATALOG["i-have-adhd"];
|
||||
|
||||
function assertString(v: unknown, label: string): asserts v is string {
|
||||
assert.equal(typeof v, "string", `${label} must be a string`);
|
||||
}
|
||||
|
||||
describe("i-have-adhd output style", () => {
|
||||
it("is registered in the catalog with lite/full/ultra levels", () => {
|
||||
assert.ok(ADHD, "i-have-adhd must be in catalog");
|
||||
assert.equal(ADHD.id, "i-have-adhd");
|
||||
assert.ok(ADHD.label.includes("ADHD"));
|
||||
assertString(ADHD.levels.lite, "lite");
|
||||
assertString(ADHD.levels.full, "full");
|
||||
assertString(ADHD.levels.ultra, "ultra");
|
||||
});
|
||||
|
||||
it("every level ends with the shared boundaries suffix", () => {
|
||||
const shared = "Code blocks, file paths";
|
||||
assert.ok(ADHD.levels.lite.includes(shared));
|
||||
assert.ok(ADHD.levels.full.includes(shared));
|
||||
assert.ok(ADHD.levels.ultra.includes(shared));
|
||||
});
|
||||
|
||||
it("the full level contains the action-first core concepts", () => {
|
||||
assert.ok(ADHD.levels.full.includes("Lead with the next action"));
|
||||
assert.ok(/[Nn]umber/.test(ADHD.levels.full), "full mentions numbered steps");
|
||||
assert.ok(ADHD.levels.full.includes("No preamble"));
|
||||
});
|
||||
|
||||
it("has an i18n map for pt-BR with all three intensity levels", () => {
|
||||
assert.ok(ADHD.i18n, "i18n must be defined");
|
||||
const pt = ADHD.i18n["pt-BR"];
|
||||
assert.ok(pt, "pt-BR must exist");
|
||||
assertString(pt.lite, "pt-BR.lite");
|
||||
assertString(pt.full, "pt-BR.full");
|
||||
assertString(pt.ultra, "pt-BR.ultra");
|
||||
});
|
||||
|
||||
it("each pt-BR level ends with shared boundaries", () => {
|
||||
const shared = "Code blocks";
|
||||
const pt = ADHD.i18n?.["pt-BR"];
|
||||
assert.ok(pt, "pt-BR i18n must exist");
|
||||
assert.ok(pt.lite.includes(shared), "pt-BR.lite contains shared boundaries");
|
||||
assert.ok(pt.full.includes(shared), "pt-BR.full contains shared boundaries");
|
||||
assert.ok(pt.ultra.includes(shared), "pt-BR.ultra contains shared boundaries");
|
||||
});
|
||||
|
||||
it("pt-BR full contains Portuguese action-first terminology", () => {
|
||||
const pt = ADHD.i18n?.["pt-BR"];
|
||||
assert.ok(pt, "pt-BR i18n must exist");
|
||||
assert.ok(/ação/.test(pt.full), "pt-BR.full mentions ação");
|
||||
assert.ok(/preâmbulo/.test(pt.full), "pt-BR.full mentions preâmbulo");
|
||||
});
|
||||
|
||||
it("carries no locale gate", () => {
|
||||
assert.equal(outputStyleMeta("i-have-adhd").locale, undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveProviderServiceKinds } from "../../open-sse/config/mediaServiceKinds.ts";
|
||||
import { AI_PROVIDERS } from "../../src/shared/constants/providers.ts";
|
||||
|
||||
test("OCR-registry providers derive imageToText without manual declaration", () => {
|
||||
assert.ok(resolveProviderServiceKinds("mistral", undefined).includes("imageToText"));
|
||||
assert.ok(
|
||||
resolveProviderServiceKinds("azure-document-intelligence", undefined).includes("imageToText")
|
||||
);
|
||||
});
|
||||
|
||||
test("non-OCR providers do not gain imageToText implicitly", () => {
|
||||
assert.ok(!resolveProviderServiceKinds("groq", undefined).includes("imageToText"));
|
||||
});
|
||||
|
||||
test("chutes declares llm + imageToText (dots.ocr seed, served via passthrough discovery)", () => {
|
||||
const kinds = resolveProviderServiceKinds("chutes", AI_PROVIDERS.chutes.serviceKinds);
|
||||
assert.ok(kinds.includes("imageToText"));
|
||||
assert.ok(kinds.includes("llm"));
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { handleOcr } from "../../open-sse/handlers/ocr.ts";
|
||||
|
||||
function fetchStub(
|
||||
script: Array<{ status: number; headers?: Record<string, string>; json?: unknown }>
|
||||
) {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const impl = async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
const step = script.shift()!;
|
||||
return new Response(step.json !== undefined ? JSON.stringify(step.json) : null, {
|
||||
status: step.status,
|
||||
headers: { "Content-Type": "application/json", ...(step.headers ?? {}) },
|
||||
});
|
||||
};
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
const noSleep = async () => {};
|
||||
|
||||
test("mistral path posts once and returns the upstream body", async () => {
|
||||
const { impl, calls } = fetchStub([
|
||||
{ status: 200, json: { pages: [{ index: 0, markdown: "ok" }], model: "mistral-ocr-latest" } },
|
||||
]);
|
||||
const res = await handleOcr({
|
||||
body: {
|
||||
model: "mistral/mistral-ocr-latest",
|
||||
document: { type: "image_url", image_url: "https://x/y.png" },
|
||||
},
|
||||
credentials: { apiKey: "sk" },
|
||||
fetchImpl: impl,
|
||||
sleepImpl: noSleep,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(calls.length, 1);
|
||||
const data = await res.json();
|
||||
assert.equal(data.pages[0].markdown, "ok");
|
||||
});
|
||||
|
||||
test("azure DI path polls Operation-Location until succeeded", async () => {
|
||||
const { impl, calls } = fetchStub([
|
||||
{ status: 202, headers: { "Operation-Location": "https://poll/op/1" } },
|
||||
{ status: 200, json: { status: "running" } },
|
||||
{ status: 200, json: { status: "succeeded", analyzeResult: { content: "# md", pages: [{}] } } },
|
||||
]);
|
||||
const res = await handleOcr({
|
||||
body: {
|
||||
model: "azure-document-intelligence/prebuilt-read",
|
||||
document: { type: "document_url", document_url: "https://x/d.pdf" },
|
||||
},
|
||||
credentials: { apiKey: "azkey", baseUrl: "https://r.cognitiveservices.azure.com" },
|
||||
fetchImpl: impl,
|
||||
sleepImpl: noSleep,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(calls.length >= 3);
|
||||
const data = await res.json();
|
||||
assert.equal(data.pages[0].markdown, "# md");
|
||||
});
|
||||
|
||||
test("unknown model lists available providers dynamically and errors do not leak internals", async () => {
|
||||
const res = await handleOcr({
|
||||
body: { model: "nope/none", document: { type: "image_url", image_url: "https://x" } },
|
||||
credentials: { apiKey: "k" },
|
||||
fetchImpl: async () => new Response("{}", { status: 200 }),
|
||||
sleepImpl: noSleep,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.message.includes("azure-document-intelligence"));
|
||||
assert.ok(!body.error.message.includes("at /"));
|
||||
});
|
||||
|
||||
test("azure DI poll returns failed status maps to 502", async () => {
|
||||
const { impl } = fetchStub([
|
||||
{ status: 202, headers: { "Operation-Location": "https://poll/op/1" } },
|
||||
{ status: 200, json: { status: "failed" } },
|
||||
]);
|
||||
const res = await handleOcr({
|
||||
body: {
|
||||
model: "azure-document-intelligence/prebuilt-read",
|
||||
document: { type: "document_url", document_url: "https://x/d.pdf" },
|
||||
},
|
||||
credentials: { apiKey: "azkey", baseUrl: "https://r.cognitiveservices.azure.com" },
|
||||
fetchImpl: impl,
|
||||
sleepImpl: noSleep,
|
||||
});
|
||||
assert.equal(res.status, 502);
|
||||
const body = await res.json();
|
||||
assert.ok(!body.error.message.includes("at /"));
|
||||
});
|
||||
|
||||
test("azure DI poll returns a non-ok response (401) and fails fast without exhausting the loop", async () => {
|
||||
const { impl, calls } = fetchStub([
|
||||
{ status: 202, headers: { "Operation-Location": "https://poll/op/1" } },
|
||||
{ status: 401, json: { error: "unauthorized" } },
|
||||
]);
|
||||
const res = await handleOcr({
|
||||
body: {
|
||||
model: "azure-document-intelligence/prebuilt-read",
|
||||
document: { type: "document_url", document_url: "https://x/d.pdf" },
|
||||
},
|
||||
credentials: { apiKey: "azkey", baseUrl: "https://r.cognitiveservices.azure.com" },
|
||||
fetchImpl: impl,
|
||||
sleepImpl: noSleep,
|
||||
});
|
||||
assert.equal(res.status, 502);
|
||||
// 1 initial POST + 1 poll: the loop stopped immediately, it did not run all 30 attempts.
|
||||
assert.equal(calls.length, 2);
|
||||
const body = await res.json();
|
||||
assert.ok(!body.error.message.includes("at /"));
|
||||
});
|
||||
|
||||
test("azure DI poll never resolves and times out after 30 attempts with a 504", async () => {
|
||||
const script = [{ status: 202, headers: { "Operation-Location": "https://poll/op/1" } }];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
script.push({ status: 200, json: { status: "running" } });
|
||||
}
|
||||
const { impl, calls } = fetchStub(script);
|
||||
const res = await handleOcr({
|
||||
body: {
|
||||
model: "azure-document-intelligence/prebuilt-read",
|
||||
document: { type: "document_url", document_url: "https://x/d.pdf" },
|
||||
},
|
||||
credentials: { apiKey: "azkey", baseUrl: "https://r.cognitiveservices.azure.com" },
|
||||
fetchImpl: impl,
|
||||
sleepImpl: noSleep,
|
||||
});
|
||||
assert.equal(res.status, 504);
|
||||
// 1 initial POST + 30 poll attempts (the max cap), no more.
|
||||
assert.equal(calls.length, 31);
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
OCR_PROVIDERS,
|
||||
getOcrTransformation,
|
||||
MISTRAL_PASSTHROUGH,
|
||||
} from "../../open-sse/config/ocrRegistry.ts";
|
||||
|
||||
test("mistral resolves the passthrough transformation by default", () => {
|
||||
const t = getOcrTransformation("mistral");
|
||||
assert.equal(t, MISTRAL_PASSTHROUGH);
|
||||
const { url, init } = t.buildRequest({
|
||||
baseUrl: OCR_PROVIDERS.mistral.baseUrl,
|
||||
token: "sk-test",
|
||||
body: { document: { type: "image_url", image_url: "https://x/y.png" } },
|
||||
modelId: "mistral-ocr-latest",
|
||||
});
|
||||
assert.equal(url, "https://api.mistral.ai/v1/ocr");
|
||||
assert.equal(init.method, "POST");
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer sk-test");
|
||||
const sent = JSON.parse(String(init.body));
|
||||
assert.equal(sent.model, "mistral-ocr-latest");
|
||||
});
|
||||
|
||||
test("passthrough parseResponse returns the body unchanged (Mistral is the canonical shape)", () => {
|
||||
const raw = { pages: [{ index: 0, markdown: "hello" }], model: "mistral-ocr-latest" };
|
||||
assert.deepEqual(MISTRAL_PASSTHROUGH.parseResponse(raw), raw);
|
||||
});
|
||||
|
||||
test("azure-document-intelligence builds the prebuilt-read:analyze request", () => {
|
||||
const t = getOcrTransformation("azure-document-intelligence");
|
||||
const { url, init } = t.buildRequest({
|
||||
baseUrl: "https://myres.cognitiveservices.azure.com",
|
||||
token: "azkey",
|
||||
body: { document: { type: "document_url", document_url: "https://x/d.pdf" } },
|
||||
modelId: "prebuilt-read",
|
||||
});
|
||||
assert.equal(
|
||||
url,
|
||||
"https://myres.cognitiveservices.azure.com/documentintelligence/documentModels/prebuilt-read:analyze?api-version=2024-11-30&outputContentFormat=markdown"
|
||||
);
|
||||
assert.equal((init.headers as Record<string, string>)["Ocp-Apim-Subscription-Key"], "azkey");
|
||||
const sent = JSON.parse(String(init.body));
|
||||
assert.equal(sent.urlSource, "https://x/d.pdf");
|
||||
});
|
||||
|
||||
test("azure-document-intelligence extracts poll URL and parses analyzeResult into Mistral shape", () => {
|
||||
const t = getOcrTransformation("azure-document-intelligence");
|
||||
const res = new Response(null, {
|
||||
status: 202,
|
||||
headers: { "Operation-Location": "https://poll/op/1" },
|
||||
});
|
||||
assert.equal(t.pollUrl?.(res), "https://poll/op/1");
|
||||
const parsed = t.parseResponse({
|
||||
status: "succeeded",
|
||||
analyzeResult: { content: "# doc text", pages: [{ pageNumber: 1 }] },
|
||||
});
|
||||
assert.equal(parsed.pages.length, 1);
|
||||
assert.equal(parsed.pages[0].index, 0);
|
||||
assert.equal(parsed.pages[0].markdown, "# doc text");
|
||||
assert.equal(parsed.model, "prebuilt-read");
|
||||
});
|
||||
|
||||
test("azure DI maps base64/image_url documents to base64Source/urlSource", () => {
|
||||
const t = getOcrTransformation("azure-document-intelligence");
|
||||
const { init } = t.buildRequest({
|
||||
baseUrl: "https://r.example.com",
|
||||
token: "k",
|
||||
body: { document: { type: "image_url", image_url: "data:image/png;base64,AAAA" } },
|
||||
modelId: "prebuilt-read",
|
||||
});
|
||||
const sent = JSON.parse(String(init.body));
|
||||
assert.equal(sent.base64Source, "AAAA");
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getAllOcrModels, parseOcrModel } from "../../open-sse/config/ocrRegistry.ts";
|
||||
import { resolveOcrCredentials } from "../../src/app/api/v1/ocr/route.ts";
|
||||
|
||||
test("getAllOcrModels exposes both the mistral and azure-document-intelligence OCR models", () => {
|
||||
const ids = getAllOcrModels().map((m) => m.id);
|
||||
assert.ok(ids.includes("mistral/mistral-ocr-latest"));
|
||||
assert.ok(ids.includes("azure-document-intelligence/prebuilt-read"));
|
||||
});
|
||||
|
||||
test("parseOcrModel resolves the azure-document-intelligence provider prefix", () => {
|
||||
assert.deepEqual(parseOcrModel("azure-document-intelligence/prebuilt-read"), {
|
||||
provider: "azure-document-intelligence",
|
||||
model: "prebuilt-read",
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveOcrCredentials — maps the connection's custom endpoint (stored
|
||||
// under providerSpecificData.baseUrl per the src/lib/providers/validation/*
|
||||
// convention) onto the top-level credentials.baseUrl field that handleOcr
|
||||
// reads, so azure-document-intelligence connections resolve their endpoint. ──
|
||||
|
||||
test("resolveOcrCredentials surfaces providerSpecificData.baseUrl to the top level", () => {
|
||||
const credentials = {
|
||||
apiKey: "azkey",
|
||||
providerSpecificData: { baseUrl: "https://r.cognitiveservices.azure.com" },
|
||||
};
|
||||
assert.deepEqual(resolveOcrCredentials(credentials), {
|
||||
apiKey: "azkey",
|
||||
providerSpecificData: { baseUrl: "https://r.cognitiveservices.azure.com" },
|
||||
baseUrl: "https://r.cognitiveservices.azure.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveOcrCredentials keeps an existing top-level baseUrl untouched", () => {
|
||||
const credentials = {
|
||||
apiKey: "azkey",
|
||||
baseUrl: "https://explicit.example.com",
|
||||
providerSpecificData: { baseUrl: "https://ignored.example.com" },
|
||||
};
|
||||
assert.equal(resolveOcrCredentials(credentials).baseUrl, "https://explicit.example.com");
|
||||
});
|
||||
|
||||
test("resolveOcrCredentials is a no-op when there is no providerSpecificData.baseUrl (mistral)", () => {
|
||||
const credentials = { apiKey: "sk-mistral" };
|
||||
assert.deepEqual(resolveOcrCredentials(credentials), credentials);
|
||||
});
|
||||
@@ -183,7 +183,6 @@ test("handleOcr returns a sanitized 500 when the upstream request throws", async
|
||||
const payload = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.ok(payload.error.message.includes("OCR request failed"));
|
||||
assert.ok(!payload.error.message.includes("socket closed"));
|
||||
assert.match(payload.error.message, /OCR request failed: socket closed/);
|
||||
assert.ok(!payload.error.message.includes("at /"));
|
||||
});
|
||||
|
||||
57
tests/unit/opencode-config-dir-single-source.test.ts
Normal file
57
tests/unit/opencode-config-dir-single-source.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Regression guard for the #10246 follow-up: `resolveOpencodeConfigDir` has exactly ONE
|
||||
// implementation, in `src/shared/services/opencodeConfigPath.ts`.
|
||||
//
|
||||
// #10246 moved the canonical resolvers into `opencodeConfigPath.ts` and left a thin wrapper
|
||||
// `resolveOpencodeConfigDir` behind in `cliRuntime.ts`. That wrapper lost its last consumer in
|
||||
// the same commit and became a dead export — which is what pushed the `check:dead-code` ratchet
|
||||
// to 410 (baseline 409) and made every PR on `release/v3.8.50` born red on that gate.
|
||||
//
|
||||
// Worse than the ratchet: the wrapper returned `path.dirname()` of the canonical value, i.e.
|
||||
// `~/.config` instead of `~/.config/opencode`. Two same-named exports with DIFFERENT return
|
||||
// values is a live foot-gun — a future caller importing from `cliRuntime` instead of
|
||||
// `opencodeConfigPath` would silently write the OpenCode config one directory too high.
|
||||
//
|
||||
// This test pins both halves: the canonical resolver's contract, and the absence of the
|
||||
// divergent re-export.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveOpencodeConfigDir } from "@/shared/services/opencodeConfigPath";
|
||||
import * as cliRuntime from "@/shared/services/cliRuntime";
|
||||
|
||||
test("#10246 canonical resolveOpencodeConfigDir returns the XDG opencode directory", () => {
|
||||
assert.equal(
|
||||
resolveOpencodeConfigDir({ XDG_CONFIG_HOME: "/xdg" }, "/home/u"),
|
||||
path.join("/xdg", "opencode")
|
||||
);
|
||||
// No XDG_CONFIG_HOME → `<home>/.config/opencode` on every platform (#3330: OpenCode reads
|
||||
// XDG even on Windows, where it uses %USERPROFILE%\.config and never %APPDATA%).
|
||||
assert.equal(
|
||||
resolveOpencodeConfigDir({}, "/home/u"),
|
||||
path.join("/home/u", ".config", "opencode")
|
||||
);
|
||||
// A blank/whitespace XDG_CONFIG_HOME must fall back, not produce a relative path.
|
||||
assert.equal(
|
||||
resolveOpencodeConfigDir({ XDG_CONFIG_HOME: " " }, "/home/u"),
|
||||
path.join("/home/u", ".config", "opencode")
|
||||
);
|
||||
});
|
||||
|
||||
test("#10246 cliRuntime does NOT re-export a divergent resolveOpencodeConfigDir", () => {
|
||||
assert.equal(
|
||||
(cliRuntime as Record<string, unknown>).resolveOpencodeConfigDir,
|
||||
undefined,
|
||||
"cliRuntime must not re-export resolveOpencodeConfigDir — the wrapper returned the PARENT " +
|
||||
"directory (path.dirname of the canonical value), so importing it by name would write the " +
|
||||
"OpenCode config one level too high. Import it from opencodeConfigPath instead."
|
||||
);
|
||||
});
|
||||
|
||||
test("#10246 cliRuntime still exposes the config PATH helpers it owns", () => {
|
||||
// The path helpers legitimately stay on cliRuntime (they have live consumers) — this guard
|
||||
// must not be read as "cliRuntime should stop exporting OpenCode helpers entirely".
|
||||
assert.equal(typeof cliRuntime.resolveOpencodeConfigPath, "function");
|
||||
assert.equal(typeof cliRuntime.getOpenCodeConfigPath, "function");
|
||||
});
|
||||
126
tests/unit/upstream-status-restatement.test.ts
Normal file
126
tests/unit/upstream-status-restatement.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* Gateways like agentrouter.org misstate TEMPORARY quota exhaustion as 403/400
|
||||
* (Chinese body "用户额度不足"), which Claude Code treats as permanent and dies.
|
||||
* applyStatusRestatement() rewrites such statuses to 429 (+ synthetic
|
||||
* Retry-After) in ONE place, before fallback classification and before the
|
||||
* status ever reaches the client. Registry-driven: future gateways with the
|
||||
* same defect register one rule array — no pipeline changes.
|
||||
*/
|
||||
|
||||
const { applyStatusRestatement, statusRestatementRegistry } = await import(
|
||||
"../../open-sse/config/upstreamStatusRestatement.ts"
|
||||
);
|
||||
|
||||
test("R1: agentrouter 403 + 用户额度不足 → 429 with synthetic Retry-After", () => {
|
||||
const out = applyStatusRestatement({
|
||||
provider: "agentrouter",
|
||||
status: 403,
|
||||
message: '{"error":{"message":"用户额度不足","type":"insufficient_user_quota"}}',
|
||||
retryAfterMs: null,
|
||||
});
|
||||
assert.equal(out.status, 429);
|
||||
assert.equal(out.fromStatus, 403);
|
||||
assert.equal(out.ruleId, "agentrouter-quota-misstatus");
|
||||
assert.equal(out.retryAfterMs, 60_000);
|
||||
});
|
||||
|
||||
test("R2: agentrouter 403 + 无权访问模型 (no model access) is NOT restated", () => {
|
||||
const out = applyStatusRestatement({
|
||||
provider: "agentrouter",
|
||||
status: 403,
|
||||
message: "无权访问模型 claude-sonnet-4",
|
||||
retryAfterMs: null,
|
||||
});
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.ruleId, null);
|
||||
});
|
||||
|
||||
test("R3: quota marker in body (not message) still restates", () => {
|
||||
const out = applyStatusRestatement({
|
||||
provider: "agentrouter",
|
||||
status: 403,
|
||||
message: "Forbidden",
|
||||
body: { error: { message: "用户额度不足,请充值" } },
|
||||
retryAfterMs: null,
|
||||
});
|
||||
assert.equal(out.status, 429);
|
||||
});
|
||||
|
||||
test("R4: upstream-provided retryAfterMs wins over the synthetic default", () => {
|
||||
const out = applyStatusRestatement({
|
||||
provider: "agentrouter",
|
||||
status: 403,
|
||||
message: "用户额度不足",
|
||||
retryAfterMs: 5_000,
|
||||
});
|
||||
assert.equal(out.status, 429);
|
||||
assert.equal(out.retryAfterMs, 5_000);
|
||||
});
|
||||
|
||||
test("R5: agentrouter 400 with quota marker also restates (gateway variant)", () => {
|
||||
const out = applyStatusRestatement({
|
||||
provider: "agentrouter",
|
||||
status: 400,
|
||||
message: "额度不足",
|
||||
retryAfterMs: null,
|
||||
});
|
||||
assert.equal(out.status, 429);
|
||||
});
|
||||
|
||||
test("R6: agentrouter 403 without quota markers is untouched (real auth error)", () => {
|
||||
const out = applyStatusRestatement({
|
||||
provider: "agentrouter",
|
||||
status: 403,
|
||||
message: "Invalid API key",
|
||||
retryAfterMs: null,
|
||||
});
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.ruleId, null);
|
||||
});
|
||||
|
||||
test("R7: other providers never match agentrouter rules (registry-scoped)", () => {
|
||||
const out = applyStatusRestatement({
|
||||
provider: "openai",
|
||||
status: 403,
|
||||
message: "用户额度不足",
|
||||
retryAfterMs: null,
|
||||
});
|
||||
assert.equal(out.status, 403);
|
||||
});
|
||||
|
||||
test("R8: statuses a rule does not list pass through (already-correct 429)", () => {
|
||||
const out = applyStatusRestatement({
|
||||
provider: "agentrouter",
|
||||
status: 429,
|
||||
message: "用户额度不足",
|
||||
retryAfterMs: 1_000,
|
||||
});
|
||||
assert.equal(out.status, 429);
|
||||
assert.equal(out.ruleId, null);
|
||||
assert.equal(out.retryAfterMs, 1_000);
|
||||
});
|
||||
|
||||
test("R9: registry exposes agentrouter so future gateways copy the one-line recipe", () => {
|
||||
const rules = statusRestatementRegistry.get("agentrouter");
|
||||
assert.ok(rules && rules.length > 0);
|
||||
});
|
||||
|
||||
test("R10: chatCore wires applyStatusRestatement into the providerFailure block", async () => {
|
||||
// chatCore is a god-file that cannot be imported standalone in unit tests
|
||||
// (side-effectful DB/env wiring), so the wiring contract is asserted at the
|
||||
// source level: the hook must exist, run against the parsed error, and
|
||||
// reassign both statusCode and retryAfterMs BEFORE classification.
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const src = await readFile(
|
||||
new URL("../../open-sse/handlers/chatCore.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
assert.match(src, /applyStatusRestatement\(/, "chatCore must call applyStatusRestatement");
|
||||
const hookIndex = src.indexOf("applyStatusRestatement(");
|
||||
const classifyIndex = src.indexOf("classifyProviderError(statusCode");
|
||||
assert.ok(hookIndex > -1 && classifyIndex > -1 && hookIndex < classifyIndex,
|
||||
"restatement must run BEFORE classifyProviderError so fallback sees the corrected status");
|
||||
});
|
||||
Reference in New Issue
Block a user