From 2b1e0e00c12d7287f9324a4a39ec7e966712affe Mon Sep 17 00:00:00 2001 From: Hako <25837994+devswha@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:39:04 +0900 Subject: [PATCH 1/2] fix(routing): evaluate effective provider capabilities --- .../docs/fr/guides/routing-profile-editor.md | 5 + .../docs/guides/routing-profile-editor.md | 5 + .../docs/tr/guides/routing-profile-editor.md | 6 +- .../zh-tw/guides/routing-profile-editor.md | 2 + src/routing/capability.ts | 13 +- src/routing/compatibility/assemble.ts | 15 +- .../routing-capability-model-matching.test.ts | 210 +++++++++++++++++- 7 files changed, 249 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md index b437c28b3d..f2577bc013 100644 --- a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md @@ -37,6 +37,11 @@ résultat du plafond. ## Simuler un profil enregistré +Les capacités des candidats utilisent la configuration effective du fournisseur, +après application du registre. Les exigences de localité (`localOnly` et +`remoteAllowed`) utilisent donc l'adresse amont effective. Si elle ne peut pas être +classée, `unknownEvidence.capability` détermine l'admissibilité du candidat. + Sélectionnez un profil enregistré et utilisez **Évaluation à sec** pour ajouter des éléments propres à la requête, tels que la taille de la fenêtre de contexte, l’utilisation d’outils, l’entrée d’images ou la sortie structurée. La simulation évalue l’admissibilité et la notation, mais n’envoie jamais de requête à un modèle en amont. Les modifications non enregistrées ne sont pas prises en compte par la simulation. Enregistrez d’abord le profil afin que la révision et l’évaluation affichées correspondent à la même configuration. diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md index 5cf5fc6d71..6bc7751cb7 100644 --- a/docs-site/src/content/docs/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/guides/routing-profile-editor.md @@ -38,6 +38,11 @@ cap outcome. ## Dry-run a saved profile +Candidate capabilities use the effective provider configuration after registry +overrides are applied. Locality requirements (`localOnly` and `remoteAllowed`) +therefore use the effective upstream address. If that address cannot be classified, +the profile's `unknownEvidence.capability` setting decides eligibility. + Select a saved profile and use **Dry-run evaluation** to add request evidence such as context-window size, tool use, image input, or structured output. Dry-run evaluates eligibility and scoring but never sends an upstream model request. Unsaved edits are not used by dry-run. Save the profile first so the displayed revision and evaluation refer to the same configuration. diff --git a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md index dd7aa50d72..6a02d786a7 100644 --- a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md @@ -52,6 +52,11 @@ ayrıdır. ## Kaydedilmiş bir profilde deneme çalıştırması (dry-run) yapma +Aday yetenekleri, kayıt defteri kuralları uygulandıktan sonraki etkin sağlayıcı +yapılandırmasını kullanır. Yerellik gereksinimleri (`localOnly` ve `remoteAllowed`) +bu nedenle etkin üst sunucu adresine göre değerlendirilir. Adres sınıflandırılamıyorsa, +adayın uygunluğunu profilin `unknownEvidence.capability` ayarı belirler. + Kaydedilmiş bir profili seçin ve bağlam penceresi boyutu, araç kullanımı, görsel girişi veya yapılandırılmış çıktı gibi istek kanıtları eklemek için **Deneme çalıştırması değerlendirmesi (Dry-run evaluation)**'ı kullanın. Deneme @@ -100,4 +105,3 @@ Düzenleyici şu uç noktaları kullanır: } ``` - diff --git a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md index e6ae93a76e..2dcf9d687b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md @@ -32,6 +32,8 @@ OpenCodex 儀表板中的 **Models → Routing** 分頁可以直接管理 `confi ## 試跑已儲存的設定檔 +候選能力使用套用 registry 覆寫後的有效供應商設定。因此,本地性需求(`localOnly` 與 `remoteAllowed`)會依據實際上游位址判定。若無法分類該位址,則由設定檔的 `unknownEvidence.capability` 決定候選是否合格。 + 選取一個已儲存的設定檔,使用 **Dry-run evaluation** 加入請求證據,例如 context-window 大小、工具使用、圖片輸入或結構化輸出。試跑會評估資格與評分,但永遠不會送出上游模型請求。 未儲存的編輯不會被試跑使用。請先儲存設定檔,讓顯示的 revision 與評估參照同一份設定。 diff --git a/src/routing/capability.ts b/src/routing/capability.ts index 8495951a0f..7f26e8bbdf 100644 --- a/src/routing/capability.ts +++ b/src/routing/capability.ts @@ -10,7 +10,7 @@ * how that affects eligibility. */ -import { modelInList, type OcxConfig } from "../types"; +import { modelInList, type OcxConfig, type OcxProviderConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { serviceTierSupportForModel } from "../providers/service-tier"; import { PROVIDER_REGISTRY } from "../providers/registry"; @@ -149,14 +149,20 @@ function localRemoteEvidence(baseUrl: string | undefined): Pick entry.id === providerName); + const provider = resolvedProvider ?? config.providers[providerName]; + const registryEntry = resolvedProvider === undefined + ? PROVIDER_REGISTRY.find(entry => entry.id === providerName) + : undefined; const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId); const isNative = providerName === OPENAI_CODEX_PROVIDER_ID && !modelId.includes("/"); @@ -224,6 +230,7 @@ export function candidateCapabilityEvidence( ? [] : modelRecordValue(provider?.modelReasoningEfforts, modelId) ?? modelRecordValue(registryEntry?.modelReasoningEfforts, modelId) + ?? provider?.reasoningEfforts ?? (isNative ? nativeReasoningEfforts(modelId) : undefined); const tierSupport = provider diff --git a/src/routing/compatibility/assemble.ts b/src/routing/compatibility/assemble.ts index 1d543690a4..cbc036754f 100644 --- a/src/routing/compatibility/assemble.ts +++ b/src/routing/compatibility/assemble.ts @@ -52,11 +52,24 @@ export function assemblePolicyCandidateEvidence( return profile.candidates.map(candidate => { const key = `${candidate.provider}/${candidate.model}`; const compatibility = compatibilityByCandidate?.get(key); + const provider = config.providers[candidate.provider]; + let routed: OcxProviderConfig | undefined; + if (provider && provider.disabled !== true) { + try { + routed = options.routedProviderConfig(candidate.provider, provider); + } catch { + // An unresolved transport proves no capabilities. Do not abort healthy + // siblings: if this candidate is selected, normal route validation still + // reports its concrete error before dispatch. + } + } return { provider: candidate.provider, model: candidate.model, - capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model), + capability: routed + ? candidateCapabilityEvidence(config, candidate.provider, candidate.model, routed) + : undefined, health: policyCandidateHealthEvidence(config, candidate, now), quota: quotaEvidenceForCandidate({ provider: candidate.provider, diff --git a/tests/routing/routing-capability-model-matching.test.ts b/tests/routing/routing-capability-model-matching.test.ts index bb956c2d8d..5d85b715fd 100644 --- a/tests/routing/routing-capability-model-matching.test.ts +++ b/tests/routing/routing-capability-model-matching.test.ts @@ -1,10 +1,19 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { validateConfigCandidate } from "../../src/config"; +import { NoEligiblePolicyCandidateError, routeModel, routedProviderConfig } from "../../src/router"; import { candidateCapabilityEvidence } from "../../src/routing/capability"; +import { assemblePolicyCandidateEvidence } from "../../src/routing/compatibility/assemble"; import { evaluatePolicyProfile } from "../../src/routing/evaluator"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; +import { getRoutingProfile } from "../../src/routing/profile"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; import { modelRecordValue } from "../../src/reasoning-effort"; import { isModelTextOnly } from "../../src/vision"; -import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import type { OcxConfig, OcxProviderConfig, OcxRoutingProfileConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; /** * `candidateCapabilityEvidence` describes what the resolver will do with a candidate, @@ -35,6 +44,203 @@ function configFor(provider: OcxProviderConfig): OcxConfig { return { providers: { custom: provider } } as unknown as OcxConfig; } +describe("policy capability evidence uses the effective provider", () => { + let testDir: string; + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-effective-capability-")); + process.env.OPENCODEX_HOME = testDir; + }); + + afterEach(() => { + closeRequestHistoryIndex(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(testDir); + }); + + function policyConfig( + name: string, + provider: OcxProviderConfig, + model: string, + require: OcxRoutingProfileConfig["require"], + ): OcxConfig { + const result = validateConfigCandidate({ + port: 10100, + defaultProvider: name, + providers: { [name]: provider }, + routingProfiles: { guarded: { candidates: [{ provider: name, model }], require } }, + }); + if (!result.ok) throw new Error(result.error); + return result.config; + } + + const localOnly = { localOnly: true, remoteAllowed: false }; + const loopback = "http://127.0.0.1:11434/v1"; + + test("a loopback URL discarded by registry routing cannot satisfy a local-only policy", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "deepseek-v4-flash", localOnly); + const before = structuredClone(config); + + expect(routeModel(config, "deepseek/deepseek-v4-flash").provider.baseUrl) + .toBe("https://api.deepseek.com"); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + expect(config).toEqual(before); + }); + + test.each(["custom-local", "ollama"])("a genuine local %s endpoint remains eligible", name => { + const config = policyConfig(name, { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", localOnly); + const before = structuredClone(config); + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe(name); + expect(route.provider.baseUrl).toBe(loopback); + expect(route.routeDecision?.requirements).toEqual([ + { id: "local-only", expected: true, actual: true, outcome: "satisfied" }, + { id: "remote-allowed", expected: false, actual: false, outcome: "satisfied" }, + ]); + expect(config).toEqual(before); + }); + + test("an explicitly public endpoint remains ineligible for a local-only policy", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: "https://api.deepseek.com", + }, "deepseek-v4-flash", localOnly); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("a local candidate is selected after excluding a registry-pinned remote candidate", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "deepseek-v4-flash", localOnly); + config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; + config.routingProfiles!.guarded!.candidates.push({ provider: "local", model: "local-model" }); + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe("local"); + expect(route.provider.baseUrl).toBe(loopback); + expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); + }); + + test("registry no-vision defaults participate before policy image requirements", () => { + const config = policyConfig("deepseek", { + adapter: "openai-chat", baseUrl: "https://api.deepseek.com", + modelInputModalities: { "deepseek-v4-flash": ["text", "image"] }, + }, "deepseek-v4-flash", { imageInput: true }); + const routed = routeModel(config, "deepseek/deepseek-v4-flash"); + expect(isModelTextOnly(routed.provider, routed.modelId)).toBe(true); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("the effective model context ceiling gates a policy requirement", () => { + const config = policyConfig("openai-apikey", { + adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", + modelContextWindows: { "gpt-6-astra": 2_000_000 }, + }, "gpt-6-astra", { minContextWindow: 1_500_000 }); + const routed = routeModel(config, "openai-apikey/gpt-6-astra"); + expect(routed.provider.modelContextWindows?.["gpt-6-astra"]).toBe(1_050_000); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("canonical forward auth filled by routing satisfies the encrypted-task requirement", () => { + const config = policyConfig("openai", { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + }, "gpt-5.5", { encryptedCodexTasks: true }); + + const route = routeModel(config, "policy/guarded"); + expect(route.provider.authMode).toBe("forward"); + expect(route.routeDecision?.candidates[0]?.capability?.encryptedCodexTasks).toBe(true); + expect(config.providers.openai!.authMode).toBeUndefined(); + }); + + test("the effective provider-wide reasoning ladder participates in policy selection", () => { + const config = policyConfig("xiaomi-mimo", { + adapter: "openai-chat", baseUrl: "https://api.xiaomimimo.com/v1", + }, "mimo-v2.5", { reasoningEffort: "high" }); + + const route = routeModel(config, "policy/guarded"); + expect(route.provider.reasoningEfforts).toEqual(["low", "medium", "high"]); + expect(route.routeDecision?.candidates[0]?.capability?.reasoningEfforts) + .toEqual(["low", "medium", "high"]); + expect(config.providers["xiaomi-mimo"]!.reasoningEfforts).toBeUndefined(); + }); + + test("a same-named custom transport does not inherit an unrelated registry model map", () => { + const config = policyConfig("meta-model", { + adapter: "openai-responses", baseUrl: "https://custom.example/v1", + }, "muse-spark-1.3", { reasoningEffort: "high" }); + const routed = routeModel(config, "meta-model/muse-spark-1.3"); + expect(routed.provider.baseUrl).toBe("https://custom.example/v1"); + expect(routed.provider.modelReasoningEfforts).toBeUndefined(); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("an invalid unselected transport cannot prevent a healthy sibling from routing", () => { + const config = policyConfig("local", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", {}); + config.providers.ollama = { adapter: "openai-chat", baseUrl: " " }; + config.routingProfiles!.guarded!.candidates.push({ provider: "ollama", model: "local-model" }); + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe("local"); + expect(route.provider.baseUrl).toBe(loopback); + expect(route.routeDecision?.candidates[1]?.capability).toBeUndefined(); + }); + + test("an unresolved transport contributes no positive capability evidence", () => { + const config = policyConfig("ollama", { + adapter: "openai-chat", baseUrl: loopback, + modelInputModalities: { "local-model": ["text", "image"] }, + }, "local-model", { imageInput: true }); + config.providers.ollama!.baseUrl = " "; + + const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { + routedProviderConfig, + }); + expect(evidence[0]?.capability).toBeUndefined(); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + }); + + test("missing and disabled providers are not resolved for capability evidence", () => { + const config = policyConfig("local", { + adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true, + }, "local-model", { tools: true }); + config.providers.disabled = { ...config.providers.local!, disabled: true }; + config.routingProfiles!.guarded!.candidates.push( + { provider: "missing", model: "model" }, + { provider: "disabled", model: "model" }, + ); + const resolved: string[] = []; + const evidence = assemblePolicyCandidateEvidence(config, getRoutingProfile(config, "guarded")!, Date.now(), { + routedProviderConfig: (name, provider) => { + resolved.push(name); + return routedProviderConfig(name, provider); + }, + }); + + expect(resolved).toEqual(["local"]); + expect(evidence[0]?.capability?.tools).toBe(true); + expect(evidence[1]?.capability).toBeUndefined(); + expect(evidence[2]?.capability).toBeUndefined(); + }); + + test("a selected invalid transport still fails the normal route validation", () => { + const config = policyConfig("ollama", { + adapter: "openai-chat", baseUrl: loopback, + }, "local-model", {}); + config.providers.ollama!.baseUrl = " "; + + expect(() => routeModel(config, "policy/guarded")).toThrow('Invalid baseUrl for provider "ollama"'); + }); +}); + describe("candidateCapabilityEvidence model matching", () => { test("a family entry covers its tagged siblings, as the resolver does", () => { const provider = providerWithFamilyEntries(); From 7b1beb9c5eacd8dde22681a5df26804be52380b8 Mon Sep 17 00:00:00 2001 From: Hako <25837994+devswha@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:52:06 +0900 Subject: [PATCH 2/2] fix(routing): exclude unresolved policy transports before selection --- .../docs/fr/guides/routing-profile-editor.md | 6 +++-- .../docs/guides/routing-profile-editor.md | 2 ++ .../docs/tr/guides/routing-profile-editor.md | 3 ++- .../zh-tw/guides/routing-profile-editor.md | 1 + src/routing/compatibility/assemble.ts | 8 +++--- src/routing/evaluator.ts | 6 ++++- .../routing-capability-model-matching.test.ts | 25 +++++++++++++++++-- 7 files changed, 42 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md index f2577bc013..84b4410f77 100644 --- a/docs-site/src/content/docs/fr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/fr/guides/routing-profile-editor.md @@ -39,8 +39,10 @@ résultat du plafond. Les capacités des candidats utilisent la configuration effective du fournisseur, après application du registre. Les exigences de localité (`localOnly` et -`remoteAllowed`) utilisent donc l'adresse amont effective. Si elle ne peut pas être -classée, `unknownEvidence.capability` détermine l'admissibilité du candidat. +`remoteAllowed`) utilisent donc l’adresse amont effective. Si elle ne peut pas être +classée, `unknownEvidence.capability` détermine l’admissibilité du candidat. +Une configuration de fournisseur invalide qui ne peut pas être résolue est toujours +exclue avec `route-unavailable`, même si les capacités inconnues sont autorisées. Sélectionnez un profil enregistré et utilisez **Évaluation à sec** pour ajouter des éléments propres à la requête, tels que la taille de la fenêtre de contexte, l’utilisation d’outils, l’entrée d’images ou la sortie structurée. La simulation évalue l’admissibilité et la notation, mais n’envoie jamais de requête à un modèle en amont. diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md index 6bc7751cb7..7931f29ad6 100644 --- a/docs-site/src/content/docs/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/guides/routing-profile-editor.md @@ -42,6 +42,8 @@ Candidate capabilities use the effective provider configuration after registry overrides are applied. Locality requirements (`localOnly` and `remoteAllowed`) therefore use the effective upstream address. If that address cannot be classified, the profile's `unknownEvidence.capability` setting decides eligibility. +An invalid provider configuration that cannot be resolved is always excluded with +`route-unavailable`, even when unknown capabilities are allowed. Select a saved profile and use **Dry-run evaluation** to add request evidence such as context-window size, tool use, image input, or structured output. Dry-run evaluates eligibility and scoring but never sends an upstream model request. diff --git a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md index 6a02d786a7..ec75bdd17a 100644 --- a/docs-site/src/content/docs/tr/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/tr/guides/routing-profile-editor.md @@ -56,6 +56,8 @@ Aday yetenekleri, kayıt defteri kuralları uygulandıktan sonraki etkin sağlay yapılandırmasını kullanır. Yerellik gereksinimleri (`localOnly` ve `remoteAllowed`) bu nedenle etkin üst sunucu adresine göre değerlendirilir. Adres sınıflandırılamıyorsa, adayın uygunluğunu profilin `unknownEvidence.capability` ayarı belirler. +Çözümlenemeyen geçersiz sağlayıcı yapılandırmaları, bilinmeyen yeteneklere izin +verilse bile `route-unavailable` ile her zaman dışlanır. Kaydedilmiş bir profili seçin ve bağlam penceresi boyutu, araç kullanımı, görsel girişi veya yapılandırılmış çıktı gibi istek kanıtları eklemek için **Deneme @@ -104,4 +106,3 @@ Düzenleyici şu uç noktaları kullanır: } } ``` - diff --git a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md index 2dcf9d687b..0b54e70d57 100644 --- a/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/zh-tw/guides/routing-profile-editor.md @@ -33,6 +33,7 @@ OpenCodex 儀表板中的 **Models → Routing** 分頁可以直接管理 `confi ## 試跑已儲存的設定檔 候選能力使用套用 registry 覆寫後的有效供應商設定。因此,本地性需求(`localOnly` 與 `remoteAllowed`)會依據實際上游位址判定。若無法分類該位址,則由設定檔的 `unknownEvidence.capability` 決定候選是否合格。 +無法解析的無效供應商設定一律以 `route-unavailable` 排除,即使原則允許未知能力也是如此。 選取一個已儲存的設定檔,使用 **Dry-run evaluation** 加入請求證據,例如 context-window 大小、工具使用、圖片輸入或結構化輸出。試跑會評估資格與評分,但永遠不會送出上游模型請求。 diff --git a/src/routing/compatibility/assemble.ts b/src/routing/compatibility/assemble.ts index cbc036754f..d7cebc94bb 100644 --- a/src/routing/compatibility/assemble.ts +++ b/src/routing/compatibility/assemble.ts @@ -54,19 +54,21 @@ export function assemblePolicyCandidateEvidence( const compatibility = compatibilityByCandidate?.get(key); const provider = config.providers[candidate.provider]; let routed: OcxProviderConfig | undefined; + let routeResolutionFailed = false; if (provider && provider.disabled !== true) { try { routed = options.routedProviderConfig(candidate.provider, provider); } catch { - // An unresolved transport proves no capabilities. Do not abort healthy - // siblings: if this candidate is selected, normal route validation still - // reports its concrete error before dispatch. + // This is known unavailability, not unknown capability evidence. Keep + // the failure separate so permissive unknown policies cannot select it. + routeResolutionFailed = true; } } return { provider: candidate.provider, model: candidate.model, + ...(routeResolutionFailed ? { routeResolutionFailed: true } : {}), capability: routed ? candidateCapabilityEvidence(config, candidate.provider, candidate.model, routed) : undefined, diff --git a/src/routing/evaluator.ts b/src/routing/evaluator.ts index a07b833063..7cf801bfe9 100644 --- a/src/routing/evaluator.ts +++ b/src/routing/evaluator.ts @@ -54,6 +54,8 @@ export interface PolicyCandidateEvidence { accountRef?: string; /** Codex pool account id (provider "openai"); used to derive account-scoped quota evidence. */ codexAccountId?: string; + /** A failed effective-transport resolution excludes the candidate under every unknown policy. */ + routeResolutionFailed?: boolean; capability?: RouteCapabilityEvidence; health?: RouteHealthEvidence; quota?: RouteQuotaEvidence; @@ -278,6 +280,8 @@ export function evaluatePolicyProfile( ...requestRequirementFor(requestEvidence, evidence.capability), ]; const exclusions: RouteExclusionReason[] = []; + const routeUnavailable = evidence.routeResolutionFailed === true; + if (routeUnavailable) exclusions.push({ code: "route-unavailable" }); const bad = unsatisfiedOrUnknown(requirements); for (const requirement of bad) { if (requirement.outcome === "unsatisfied") { @@ -310,7 +314,7 @@ export function evaluatePolicyProfile( if (unknownCostBlocked) { exclusions.push({ code: "cost-limit-unknown", detail: "maxEstimatedCostUsd" }); } - let eligible = !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; + let eligible = !routeUnavailable && !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked; // Trace/dry-run copy only: report the profile cap that was applied and the // operator-visible outcome. Do not feed this copy into costScore() — that diff --git a/tests/routing/routing-capability-model-matching.test.ts b/tests/routing/routing-capability-model-matching.test.ts index 5d85b715fd..509eeec2ea 100644 --- a/tests/routing/routing-capability-model-matching.test.ts +++ b/tests/routing/routing-capability-model-matching.test.ts @@ -231,13 +231,34 @@ describe("policy capability evidence uses the effective provider", () => { expect(evidence[2]?.capability).toBeUndefined(); }); - test("a selected invalid transport still fails the normal route validation", () => { + test.each(["allow", "penalize", "exclude"] as const)( + "an unresolved first candidate is excluded when unknown capabilities are %s", + capability => { + const config = policyConfig("ollama", { + adapter: "openai-chat", baseUrl: loopback, + }, "local-model", {}); + config.providers.ollama!.baseUrl = " "; + config.providers.local = { adapter: "openai-chat", baseUrl: loopback, allowPrivateNetwork: true }; + const profile = config.routingProfiles!.guarded!; + profile.candidates.push({ provider: "local", model: "local-model" }); + profile.unknownEvidence = { ...profile.unknownEvidence, capability }; + + const route = routeModel(config, "policy/guarded"); + expect(route.providerName).toBe("local"); + expect(route.routeDecision?.candidates.map(candidate => candidate.eligible)).toEqual([false, true]); + expect(route.routeDecision?.candidates[0]?.exclusions).toContainEqual({ code: "route-unavailable" }); + expect(JSON.stringify(route.routeDecision)).not.toContain("Invalid baseUrl"); + }, + ); + + test("all unresolved candidates produce a policy exclusion while explicit routing keeps validation", () => { const config = policyConfig("ollama", { adapter: "openai-chat", baseUrl: loopback, }, "local-model", {}); config.providers.ollama!.baseUrl = " "; - expect(() => routeModel(config, "policy/guarded")).toThrow('Invalid baseUrl for provider "ollama"'); + expect(() => routeModel(config, "policy/guarded")).toThrow(NoEligiblePolicyCandidateError); + expect(() => routeModel(config, "ollama/local-model")).toThrow('Invalid baseUrl for provider "ollama"'); }); });