feat(providers): add safe model discovery#406
Conversation
📝 WalkthroughWalkthroughThe change adds a free-provider directory backed by registry metadata, exposes preset discovery with trusted endpoint validation and live/static fallbacks, normalizes multiple model response shapes, and verifies directory-only provider routing and catalog behavior. ChangesProvider catalog and discovery
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant ProviderRoutes
participant ProviderRegistry
participant UpstreamProvider
Client->>ProviderRoutes: POST /api/provider-presets/discover
ProviderRoutes->>ProviderRegistry: Resolve preset and trusted base URL
ProviderRoutes->>UpstreamProvider: Fetch models with validated destination
UpstreamProvider-->>ProviderRoutes: Model response
ProviderRoutes-->>Client: Normalized models and discovery source
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c42716deea
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const allowsModelsEnvelope = (prov.adapter === "google" && (prov.googleMode ?? "ai-studio") === "ai-studio") | ||
| || new URL(url).pathname.replace(/\/+$/, "").endsWith("/api/tags"); |
There was a problem hiding this comment.
Accept Fireworks' trusted models envelope
When the provider is fireworks, buildModelsRequest() now uses the trusted modelsUrl configured for https://api.fireworks.ai/v1/accounts/fireworks/models?...; that endpoint returns a { models: [...] } catalog, but this predicate only enables models envelopes for Google AI Studio or /api/tags. As a result parseProviderModelsApiItems returns null, live discovery is marked malformed, and Fireworks has no static models to fall back to, so the catalog/preset discovery exposes zero models. Include the trusted Fireworks models URL (and the duplicate preset-discover predicate) or allow models envelopes for trusted modelsUrls.
Useful? React with 👍 / 👎.
| const models = [...uniqueRows.values()].map(row => ({ | ||
| id: row.id, | ||
| ...(row.context_length ? { contextWindow: row.context_length } : {}), | ||
| })); |
There was a problem hiding this comment.
Filter preset discovery through provider visibility rules
When probing a preset such as opencode-free, this new discover endpoint returns every live row from upstream, but the normal catalog path deliberately filters that provider through shouldExposeProviderModel so only big-pickle and *-free IDs are exposed. If the GUI uses this response to populate selectable/saved models, non-free OpenCode Zen models like kimi-k2.7-code can be offered even though routing later removes or rejects them. Apply the same provider visibility filter before returning the discovered list.
Useful? React with 👍 / 👎.
| const rawId = typeof item.id === "string" ? item.id | ||
| : typeof item.name === "string" ? item.name | ||
| : typeof item.model === "string" ? item.model |
There was a problem hiding this comment.
Filter non-generative Google model rows
For Google AI Studio, /v1beta/models includes entries such as embedding-only models whose supportedGenerationMethods do not include generateContent, but this parser accepts every row with a name and publishes it as a chat model. After a successful live fetch, users can select IDs like text-embedding-004 and then every chat request fails at the Gemini generateContent endpoint. Preserve or inspect the Google row metadata and skip models that cannot generate content.
Useful? React with 👍 / 👎.
| const { resolveModelsAuthToken, buildModelsRequest } = await import("../../oauth"); | ||
| const apiKey = await resolveModelsAuthToken(presetId, provider); | ||
| const request = buildModelsRequest(provider, apiKey, presetId); | ||
| const modelsDestinationError = await providerDestinationResolvedError(presetId, { baseUrl: request.url }); |
There was a problem hiding this comment.
Preserve allowPrivateNetwork for model URL checks
For custom endpoint presets where the user intentionally sets allowPrivateNetwork: true (for example a local LiteLLM/Qwen-compatible endpoint), the first destination check passes, but this second check rebuilds the object with only baseUrl. The derived /models URL is then rejected as localhost/private even though the provider config allowed it, so preset discovery cannot work for intentionally local or self-hosted overrides. Pass through the provider's allowPrivateNetwork flag here.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/providers/free-directory.ts`:
- Around line 65-126: Constrain the keys of CONNECTABLE to the union of provider
access-group IDs derived from the existing group arrays, rather than using
Record<string, ConnectableOverride>. Preserve the current entries and
ConnectableOverride value typing so any future misspelled or unknown provider
key fails at compile time.
In `@src/server/management/provider-routes.ts`:
- Around line 424-448: Update the base URL validation around suppliedBaseUrl,
matchesResolvedTemplate, and acceptsResolvedBaseUrl so presets with templated
entry.baseUrl always require a concrete resolved URL. Reject omitted baseUrl
values and values that still equal the unresolved template, while preserving
valid resolved-template, official-choice, and explicitly allowed overrides;
ensure providerConfigSeed never receives a literal placeholder URL.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 221ecf32-5ebe-41d8-93e1-2eaac8c92e2f
📒 Files selected for processing (11)
src/codex/catalog/provider-fetch.tssrc/oauth/index.tssrc/providers/derive.tssrc/providers/free-directory.tssrc/providers/registry.tssrc/server/management/provider-routes.tstests/google-models-listing.test.tstests/provider-directory-routing.test.tstests/provider-free-directory.test.tstests/provider-model-envelope.test.tstests/provider-registry-parity.test.ts
| const CONNECTABLE: Record<string, ConnectableOverride> = { | ||
| kiro: { baseUrl: "https://runtime.us-east-1.kiro.dev", dashboardUrl: "https://kiro.dev", adapter: "kiro", authKind: "oauth", supportLevel: "supported", verification: "official", documentationUrl: "https://kiro.dev/docs/cli/authentication/", discovery: "static", liveModels: false }, | ||
| aihorde: openAi("https://oai.aihorde.net/v1", "https://aihorde.net", { modelsUrl: "https://oai.aihorde.net/v1/models" }), | ||
| "arcee-ai": openAi("https://conductor.arcee.ai/v1", "https://conductor.arcee.ai", { verification: "official", documentationUrl: "https://docs.arcee.ai/" }), | ||
| cerebras: openAi("https://api.cerebras.ai/v1", "https://cloud.cerebras.ai/platform/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://inference-docs.cerebras.ai/api-reference/models/list" }), | ||
| "cloudflare-ai": openAi("https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", "https://dash.cloudflare.com/?to=/:account/ai/workers-ai", { supportLevel: "supported", verification: "official", documentationUrl: "https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/", discovery: "static", liveModels: false, models: ["@cf/meta/llama-3.3-70b-instruct-fp8-fast", "@cf/qwen/qwq-32b"] }), | ||
| cohere: openAi("https://api.cohere.com/compatibility/v1", "https://dashboard.cohere.com/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.cohere.com/reference/list-models", modelsUrl: "https://api.cohere.com/compatibility/v1/models" }), | ||
| friendliai: openAi("https://api.friendli.ai/serverless/v1", "https://suite.friendli.ai", { modelsUrl: "https://api.friendli.ai/serverless/v1/models" }), | ||
| gemini: { baseUrl: "https://generativelanguage.googleapis.com", dashboardUrl: "https://aistudio.google.com/apikey", adapter: "google", authKind: "key", supportLevel: "supported", verification: "official", documentationUrl: "https://ai.google.dev/api/models", discovery: "live", liveModels: true, googleMode: "ai-studio", models: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview"] }, | ||
| "github-models": openAi("https://models.github.ai/inference", "https://github.com/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.github.com/en/github-models/prototyping-with-ai-models", discovery: "static", liveModels: false, models: ["openai/gpt-4.1", "meta/llama-4-scout-17b-16e-instruct"] }), | ||
| groq: openAi("https://api.groq.com/openai/v1", "https://console.groq.com/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://console.groq.com/docs/api-reference#models" }), | ||
| hackclub: openAi("https://ai.hackclub.com/proxy/v1", "https://ai.hackclub.com", { modelsUrl: "https://ai.hackclub.com/proxy/v1/models" }), | ||
| huggingface: openAi("https://router.huggingface.co/v1", "https://huggingface.co/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://huggingface.co/docs/inference-providers/index", modelsUrl: "https://router.huggingface.co/v1/models" }), | ||
| iflytek: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/Web.html" }), | ||
| liquid: openAi("https://inference.liquid.ai/v1", "https://playground.liquid.ai", { modelsUrl: "https://inference.liquid.ai/v1/models" }), | ||
| mistral: openAi("https://api.mistral.ai/v1", "https://console.mistral.ai/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.mistral.ai/api/endpoint/models" }), | ||
| morph: openAi("https://api.morphllm.com/v1", "https://morphllm.com", { verification: "official", documentationUrl: "https://docs.morphllm.com/api-reference/introduction" }), | ||
| "ollama-cloud": openAi("https://ollama.com/v1", "https://ollama.com/settings/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ollama.com/cloud", modelsUrl: "https://ollama.com/api/tags" }), | ||
| opencode: openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/", modelsUrl: "https://opencode.ai/zen/v1/models" }), | ||
| openrouter: openAi("https://openrouter.ai/api/v1", "https://openrouter.ai/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://openrouter.ai/docs/api/api-reference/models/get-models" }), | ||
| ovhcloud: openAi("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", "https://console.ovhcloud.com", { keyOptional: true, modelsUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models" }), | ||
| pollinations: openAi("https://gen.pollinations.ai/v1", "https://pollinations.ai", { keyOptional: true }), | ||
| reka: openAi("https://api.reka.ai/v1", "https://platform.reka.ai"), | ||
| sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/cloud/docs/api-reference/models" }), | ||
| sparkdesk: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/Web.html" }), | ||
| agnes: openAi("https://apihub.agnes-ai.com/v1", "https://agnes-ai.com"), | ||
| ainative: openAi("https://api.ainative.studio/api/v1", "https://ainative.studio", { modelsUrl: "https://api.ainative.studio/api/v1/models" }), | ||
| aion: openAi("https://api.aionlabs.ai/v1", "https://aionlabs.ai", { modelsUrl: "https://api.aionlabs.ai/v1/models" }), | ||
| baidu: openAi("https://qianfan.baidubce.com/v2", "https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application", { verification: "official", documentationUrl: "https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Fm2vrveyu", discovery: "static", liveModels: false, models: ["ernie-5.1", "ernie-5.0", "ernie-4.5-turbo-128k"] }), | ||
| glm: openAi("https://api.z.ai/api/coding/paas/v4", "https://z.ai/manage-apikey/apikey-list", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.z.ai/guides/overview/quick-start", discovery: "static", liveModels: false, models: ["glm-5"] }), | ||
| "glm-cn": openAi("https://open.bigmodel.cn/api/coding/paas/v4", "https://open.bigmodel.cn/usercenter/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.bigmodel.cn/cn/api/introduction", discovery: "static", liveModels: false, models: ["glm-4.5-flash"] }), | ||
| "kilo-gateway": openAi("https://api.kilo.ai/api/gateway", "https://kilo.ai", { modelsUrl: "https://api.kilo.ai/api/gateway/models" }), | ||
| "opencode-zen": openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/" }), | ||
| requesty: openAi("https://router.requesty.ai/v1", "https://app.requesty.ai", { modelsUrl: "https://router.requesty.ai/v1/models" }), | ||
| routeway: openAi("https://api.routeway.ai/v1", "https://routeway.ai", { modelsUrl: "https://api.routeway.ai/v1/models" }), | ||
| sealion: openAi("https://api.sea-lion.ai/v1", "https://sea-lion.ai", { discovery: "static", liveModels: false, models: ["aisingapore/Llama-SEA-LION-v3.5-70B-R", "aisingapore/Llama-SEA-LION-v3-70B-IT", "aisingapore/Gemma-SEA-LION-v4-27B-IT", "aisingapore/Qwen-SEA-LION-v4.5-27B-IT", "aisingapore/Qwen-SEA-LION-v4-32B-IT"] }), | ||
| siliconflow: openAi("https://api.siliconflow.cn/v1", "https://cloud.siliconflow.cn/account/ak", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.siliconflow.cn/en/api-reference/models/get-model-list" }), | ||
| tencent: openAi("https://api.hunyuan.cloud.tencent.com/v1", "https://console.cloud.tencent.com/hunyuan/api-key", { verification: "official", documentationUrl: "https://cloud.tencent.com/document/product/1729/111007", discovery: "static", liveModels: false, models: ["hunyuan-turbos-latest", "hunyuan-t1-latest", "hunyuan-pro", "hunyuan-lite"] }), | ||
| bytez: openAi("https://api.bytez.com/models/v2/openai/v1", "https://bytez.com", { verification: "unverified", documentationUrl: "https://docs.bytez.com/", discovery: "static", liveModels: false, models: ["meta-llama/Llama-3.3-70B-Instruct", "mistralai/Mistral-7B-Instruct-v0.3", "Qwen/Qwen2.5-72B-Instruct"], note: "The recurring-credit classification is retained from the requested catalog, but the current reset terms could not be independently verified." }), | ||
| "nous-research": openAi("https://inference-api.nousresearch.com/v1", "https://portal.nousresearch.com", { discovery: "static", liveModels: false, models: ["Hermes-4-405B", "Hermes-4-70B"] }), | ||
| agentrouter: { baseUrl: "https://agentrouter.org", dashboardUrl: "https://agentrouter.org", adapter: "anthropic", authKind: "key", supportLevel: "experimental", verification: "primary", modelsUrl: "https://agentrouter.org/v1/models", discovery: "live", liveModels: true }, | ||
| ai21: openAi("https://api.ai21.com/studio/v1", "https://studio.ai21.com/account/api-key", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ai21.com/reference/models" }), | ||
| baichuan: openAi("https://api.baichuan-ai.com/v1", "https://platform.baichuan-ai.com/console/apikey", { verification: "official" }), | ||
| deepinfra: openAi("https://api.deepinfra.com/v1/openai", "https://deepinfra.com/dash/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://deepinfra.com/docs/openai_api" }), | ||
| deepseek: openAi("https://api.deepseek.com", "https://platform.deepseek.com/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://api-docs.deepseek.com/api/list-models" }), | ||
| doubao: openAi("https://ark.cn-beijing.volces.com/api/v3", "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", { verification: "official" }), | ||
| fireworks: openAi("https://api.fireworks.ai/inference/v1", "https://fireworks.ai/account/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.fireworks.ai/api-reference/list-models", modelsUrl: "https://api.fireworks.ai/v1/accounts/fireworks/models?filter=supports_serverless=true" }), | ||
| "freemodel-dev": openAi("https://api.freemodel.dev/v1", "https://freemodel.dev", { modelsUrl: "https://api.freemodel.dev/v1/models" }), | ||
| hyperbolic: openAi("https://api.hyperbolic.xyz/v1", "https://app.hyperbolic.xyz/settings", { verification: "official" }), | ||
| longcat: openAi("https://api.longcat.chat/openai/v1", "https://longcat.chat", { verification: "official", discovery: "static", liveModels: false, models: ["LongCat-2.0"] }), | ||
| monsterapi: openAi("https://api.monsterapi.ai/v1", "https://monsterapi.ai", { verification: "official" }), | ||
| nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://studio.nebius.com", { verification: "official" }), | ||
| novita: openAi("https://api.novita.ai/openai/v1", "https://novita.ai/settings/key-management", { supportLevel: "supported", verification: "official", modelsUrl: "https://api.novita.ai/openai/v1/models" }), | ||
| nscale: openAi("https://inference.api.nscale.com/v1", "https://console.nscale.com", { verification: "official" }), | ||
| nvidia: openAi("https://integrate.api.nvidia.com/v1", "https://build.nvidia.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.api.nvidia.com/nim/reference/llm-apis" }), | ||
| publicai: openAi("https://api.publicai.co/v1", "https://publicai.co"), | ||
| scaleway: openAi("https://api.scaleway.ai/v1", "https://console.scaleway.com/generative-api", { verification: "official" }), | ||
| sensenova: openAi("https://token.sensenova.cn/v1", "https://console.sensenova.cn", { verification: "official" }), | ||
| stepfun: openAi("https://api.stepfun.com/v1", "https://platform.stepfun.com", { verification: "official" }), | ||
| together: openAi("https://api.together.xyz/v1", "https://api.together.xyz/settings/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.together.ai/reference/models-1" }), | ||
| vertex: { baseUrl: "https://aiplatform.googleapis.com", dashboardUrl: "https://console.cloud.google.com/vertex-ai", adapter: "google", authKind: "key", supportLevel: "experimental", verification: "official", documentationUrl: "https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference", discovery: "static", liveModels: false, googleMode: "vertex", models: ["gemini-3.1-pro-preview", "gemini-3.1-flash-lite", "gemini-3-flash-preview"], note: "Vertex accepts an express-mode API key here. ADC users can instead configure project and location through the existing Google Vertex provider." }, | ||
| }; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
CONNECTABLE keys aren't checked against the access-group ids at compile time.
CONNECTABLE: Record<string, ConnectableOverride> (Line 65) accepts any string key. A typo in a key (e.g. "opnrouter" instead of "openrouter") would compile fine and silently leave the intended provider on the "reference-only" default (Line 152-167) with no warning — the exact kind of bug the as const satisfies Record<ProviderAccessGroup, readonly string[]> pattern on Line 25 already guards against for the groups themselves. Consider deriving a union of all valid ids the same way and constraining CONNECTABLE's keys to it:
♻️ Proposed type tightening
+type FreeProviderId = (typeof FREE_PROVIDER_ACCESS_GROUPS)[ProviderAccessGroup][number];
+
-const CONNECTABLE: Record<string, ConnectableOverride> = {
+const CONNECTABLE: Partial<Record<FreeProviderId, ConnectableOverride>> = {I manually cross-checked all current CONNECTABLE keys against the four group arrays and found no existing mismatches, so this is purely a future-proofing guard, not an active bug today.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const CONNECTABLE: Record<string, ConnectableOverride> = { | |
| kiro: { baseUrl: "https://runtime.us-east-1.kiro.dev", dashboardUrl: "https://kiro.dev", adapter: "kiro", authKind: "oauth", supportLevel: "supported", verification: "official", documentationUrl: "https://kiro.dev/docs/cli/authentication/", discovery: "static", liveModels: false }, | |
| aihorde: openAi("https://oai.aihorde.net/v1", "https://aihorde.net", { modelsUrl: "https://oai.aihorde.net/v1/models" }), | |
| "arcee-ai": openAi("https://conductor.arcee.ai/v1", "https://conductor.arcee.ai", { verification: "official", documentationUrl: "https://docs.arcee.ai/" }), | |
| cerebras: openAi("https://api.cerebras.ai/v1", "https://cloud.cerebras.ai/platform/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://inference-docs.cerebras.ai/api-reference/models/list" }), | |
| "cloudflare-ai": openAi("https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", "https://dash.cloudflare.com/?to=/:account/ai/workers-ai", { supportLevel: "supported", verification: "official", documentationUrl: "https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/", discovery: "static", liveModels: false, models: ["@cf/meta/llama-3.3-70b-instruct-fp8-fast", "@cf/qwen/qwq-32b"] }), | |
| cohere: openAi("https://api.cohere.com/compatibility/v1", "https://dashboard.cohere.com/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.cohere.com/reference/list-models", modelsUrl: "https://api.cohere.com/compatibility/v1/models" }), | |
| friendliai: openAi("https://api.friendli.ai/serverless/v1", "https://suite.friendli.ai", { modelsUrl: "https://api.friendli.ai/serverless/v1/models" }), | |
| gemini: { baseUrl: "https://generativelanguage.googleapis.com", dashboardUrl: "https://aistudio.google.com/apikey", adapter: "google", authKind: "key", supportLevel: "supported", verification: "official", documentationUrl: "https://ai.google.dev/api/models", discovery: "live", liveModels: true, googleMode: "ai-studio", models: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview"] }, | |
| "github-models": openAi("https://models.github.ai/inference", "https://github.com/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.github.com/en/github-models/prototyping-with-ai-models", discovery: "static", liveModels: false, models: ["openai/gpt-4.1", "meta/llama-4-scout-17b-16e-instruct"] }), | |
| groq: openAi("https://api.groq.com/openai/v1", "https://console.groq.com/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://console.groq.com/docs/api-reference#models" }), | |
| hackclub: openAi("https://ai.hackclub.com/proxy/v1", "https://ai.hackclub.com", { modelsUrl: "https://ai.hackclub.com/proxy/v1/models" }), | |
| huggingface: openAi("https://router.huggingface.co/v1", "https://huggingface.co/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://huggingface.co/docs/inference-providers/index", modelsUrl: "https://router.huggingface.co/v1/models" }), | |
| iflytek: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/Web.html" }), | |
| liquid: openAi("https://inference.liquid.ai/v1", "https://playground.liquid.ai", { modelsUrl: "https://inference.liquid.ai/v1/models" }), | |
| mistral: openAi("https://api.mistral.ai/v1", "https://console.mistral.ai/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.mistral.ai/api/endpoint/models" }), | |
| morph: openAi("https://api.morphllm.com/v1", "https://morphllm.com", { verification: "official", documentationUrl: "https://docs.morphllm.com/api-reference/introduction" }), | |
| "ollama-cloud": openAi("https://ollama.com/v1", "https://ollama.com/settings/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ollama.com/cloud", modelsUrl: "https://ollama.com/api/tags" }), | |
| opencode: openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/", modelsUrl: "https://opencode.ai/zen/v1/models" }), | |
| openrouter: openAi("https://openrouter.ai/api/v1", "https://openrouter.ai/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://openrouter.ai/docs/api/api-reference/models/get-models" }), | |
| ovhcloud: openAi("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", "https://console.ovhcloud.com", { keyOptional: true, modelsUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models" }), | |
| pollinations: openAi("https://gen.pollinations.ai/v1", "https://pollinations.ai", { keyOptional: true }), | |
| reka: openAi("https://api.reka.ai/v1", "https://platform.reka.ai"), | |
| sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/cloud/docs/api-reference/models" }), | |
| sparkdesk: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/Web.html" }), | |
| agnes: openAi("https://apihub.agnes-ai.com/v1", "https://agnes-ai.com"), | |
| ainative: openAi("https://api.ainative.studio/api/v1", "https://ainative.studio", { modelsUrl: "https://api.ainative.studio/api/v1/models" }), | |
| aion: openAi("https://api.aionlabs.ai/v1", "https://aionlabs.ai", { modelsUrl: "https://api.aionlabs.ai/v1/models" }), | |
| baidu: openAi("https://qianfan.baidubce.com/v2", "https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application", { verification: "official", documentationUrl: "https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Fm2vrveyu", discovery: "static", liveModels: false, models: ["ernie-5.1", "ernie-5.0", "ernie-4.5-turbo-128k"] }), | |
| glm: openAi("https://api.z.ai/api/coding/paas/v4", "https://z.ai/manage-apikey/apikey-list", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.z.ai/guides/overview/quick-start", discovery: "static", liveModels: false, models: ["glm-5"] }), | |
| "glm-cn": openAi("https://open.bigmodel.cn/api/coding/paas/v4", "https://open.bigmodel.cn/usercenter/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.bigmodel.cn/cn/api/introduction", discovery: "static", liveModels: false, models: ["glm-4.5-flash"] }), | |
| "kilo-gateway": openAi("https://api.kilo.ai/api/gateway", "https://kilo.ai", { modelsUrl: "https://api.kilo.ai/api/gateway/models" }), | |
| "opencode-zen": openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/" }), | |
| requesty: openAi("https://router.requesty.ai/v1", "https://app.requesty.ai", { modelsUrl: "https://router.requesty.ai/v1/models" }), | |
| routeway: openAi("https://api.routeway.ai/v1", "https://routeway.ai", { modelsUrl: "https://api.routeway.ai/v1/models" }), | |
| sealion: openAi("https://api.sea-lion.ai/v1", "https://sea-lion.ai", { discovery: "static", liveModels: false, models: ["aisingapore/Llama-SEA-LION-v3.5-70B-R", "aisingapore/Llama-SEA-LION-v3-70B-IT", "aisingapore/Gemma-SEA-LION-v4-27B-IT", "aisingapore/Qwen-SEA-LION-v4.5-27B-IT", "aisingapore/Qwen-SEA-LION-v4-32B-IT"] }), | |
| siliconflow: openAi("https://api.siliconflow.cn/v1", "https://cloud.siliconflow.cn/account/ak", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.siliconflow.cn/en/api-reference/models/get-model-list" }), | |
| tencent: openAi("https://api.hunyuan.cloud.tencent.com/v1", "https://console.cloud.tencent.com/hunyuan/api-key", { verification: "official", documentationUrl: "https://cloud.tencent.com/document/product/1729/111007", discovery: "static", liveModels: false, models: ["hunyuan-turbos-latest", "hunyuan-t1-latest", "hunyuan-pro", "hunyuan-lite"] }), | |
| bytez: openAi("https://api.bytez.com/models/v2/openai/v1", "https://bytez.com", { verification: "unverified", documentationUrl: "https://docs.bytez.com/", discovery: "static", liveModels: false, models: ["meta-llama/Llama-3.3-70B-Instruct", "mistralai/Mistral-7B-Instruct-v0.3", "Qwen/Qwen2.5-72B-Instruct"], note: "The recurring-credit classification is retained from the requested catalog, but the current reset terms could not be independently verified." }), | |
| "nous-research": openAi("https://inference-api.nousresearch.com/v1", "https://portal.nousresearch.com", { discovery: "static", liveModels: false, models: ["Hermes-4-405B", "Hermes-4-70B"] }), | |
| agentrouter: { baseUrl: "https://agentrouter.org", dashboardUrl: "https://agentrouter.org", adapter: "anthropic", authKind: "key", supportLevel: "experimental", verification: "primary", modelsUrl: "https://agentrouter.org/v1/models", discovery: "live", liveModels: true }, | |
| ai21: openAi("https://api.ai21.com/studio/v1", "https://studio.ai21.com/account/api-key", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ai21.com/reference/models" }), | |
| baichuan: openAi("https://api.baichuan-ai.com/v1", "https://platform.baichuan-ai.com/console/apikey", { verification: "official" }), | |
| deepinfra: openAi("https://api.deepinfra.com/v1/openai", "https://deepinfra.com/dash/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://deepinfra.com/docs/openai_api" }), | |
| deepseek: openAi("https://api.deepseek.com", "https://platform.deepseek.com/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://api-docs.deepseek.com/api/list-models" }), | |
| doubao: openAi("https://ark.cn-beijing.volces.com/api/v3", "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", { verification: "official" }), | |
| fireworks: openAi("https://api.fireworks.ai/inference/v1", "https://fireworks.ai/account/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.fireworks.ai/api-reference/list-models", modelsUrl: "https://api.fireworks.ai/v1/accounts/fireworks/models?filter=supports_serverless=true" }), | |
| "freemodel-dev": openAi("https://api.freemodel.dev/v1", "https://freemodel.dev", { modelsUrl: "https://api.freemodel.dev/v1/models" }), | |
| hyperbolic: openAi("https://api.hyperbolic.xyz/v1", "https://app.hyperbolic.xyz/settings", { verification: "official" }), | |
| longcat: openAi("https://api.longcat.chat/openai/v1", "https://longcat.chat", { verification: "official", discovery: "static", liveModels: false, models: ["LongCat-2.0"] }), | |
| monsterapi: openAi("https://api.monsterapi.ai/v1", "https://monsterapi.ai", { verification: "official" }), | |
| nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://studio.nebius.com", { verification: "official" }), | |
| novita: openAi("https://api.novita.ai/openai/v1", "https://novita.ai/settings/key-management", { supportLevel: "supported", verification: "official", modelsUrl: "https://api.novita.ai/openai/v1/models" }), | |
| nscale: openAi("https://inference.api.nscale.com/v1", "https://console.nscale.com", { verification: "official" }), | |
| nvidia: openAi("https://integrate.api.nvidia.com/v1", "https://build.nvidia.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.api.nvidia.com/nim/reference/llm-apis" }), | |
| publicai: openAi("https://api.publicai.co/v1", "https://publicai.co"), | |
| scaleway: openAi("https://api.scaleway.ai/v1", "https://console.scaleway.com/generative-api", { verification: "official" }), | |
| sensenova: openAi("https://token.sensenova.cn/v1", "https://console.sensenova.cn", { verification: "official" }), | |
| stepfun: openAi("https://api.stepfun.com/v1", "https://platform.stepfun.com", { verification: "official" }), | |
| together: openAi("https://api.together.xyz/v1", "https://api.together.xyz/settings/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.together.ai/reference/models-1" }), | |
| vertex: { baseUrl: "https://aiplatform.googleapis.com", dashboardUrl: "https://console.cloud.google.com/vertex-ai", adapter: "google", authKind: "key", supportLevel: "experimental", verification: "official", documentationUrl: "https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference", discovery: "static", liveModels: false, googleMode: "vertex", models: ["gemini-3.1-pro-preview", "gemini-3.1-flash-lite", "gemini-3-flash-preview"], note: "Vertex accepts an express-mode API key here. ADC users can instead configure project and location through the existing Google Vertex provider." }, | |
| }; | |
| type FreeProviderId = (typeof FREE_PROVIDER_ACCESS_GROUPS)[ProviderAccessGroup][number]; | |
| const CONNECTABLE: Partial<Record<FreeProviderId, ConnectableOverride>> = { | |
| kiro: { baseUrl: "https://runtime.us-east-1.kiro.dev", dashboardUrl: "https://kiro.dev", adapter: "kiro", authKind: "oauth", supportLevel: "supported", verification: "official", documentationUrl: "https://kiro.dev/docs/cli/authentication/", discovery: "static", liveModels: false }, | |
| aihorde: openAi("https://oai.aihorde.net/v1", "https://aihorde.net", { modelsUrl: "https://oai.aihorde.net/v1/models" }), | |
| "arcee-ai": openAi("https://conductor.arcee.ai/v1", "https://conductor.arcee.ai", { verification: "official", documentationUrl: "https://docs.arcee.ai/" }), | |
| cerebras: openAi("https://api.cerebras.ai/v1", "https://cloud.cerebras.ai/platform/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://inference-docs.cerebras.ai/api-reference/models/list" }), | |
| "cloudflare-ai": openAi("https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", "https://dash.cloudflare.com/?to=/:account/ai/workers-ai", { supportLevel: "supported", verification: "official", documentationUrl: "https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/", discovery: "static", liveModels: false, models: ["`@cf/meta/llama-3.3-70b-instruct-fp8-fast`", "`@cf/qwen/qwq-32b`"] }), | |
| cohere: openAi("https://api.cohere.com/compatibility/v1", "https://dashboard.cohere.com/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.cohere.com/reference/list-models", modelsUrl: "https://api.cohere.com/compatibility/v1/models" }), | |
| friendliai: openAi("https://api.friendli.ai/serverless/v1", "https://suite.friendli.ai", { modelsUrl: "https://api.friendli.ai/serverless/v1/models" }), | |
| gemini: { baseUrl: "https://generativelanguage.googleapis.com", dashboardUrl: "https://aistudio.google.com/apikey", adapter: "google", authKind: "key", supportLevel: "supported", verification: "official", documentationUrl: "https://ai.google.dev/api/models", discovery: "live", liveModels: true, googleMode: "ai-studio", models: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview"] }, | |
| "github-models": openAi("https://models.github.ai/inference", "https://github.com/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.github.com/en/github-models/prototyping-with-ai-models", discovery: "static", liveModels: false, models: ["openai/gpt-4.1", "meta/llama-4-scout-17b-16e-instruct"] }), | |
| groq: openAi("https://api.groq.com/openai/v1", "https://console.groq.com/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://console.groq.com/docs/api-reference#models" }), | |
| hackclub: openAi("https://ai.hackclub.com/proxy/v1", "https://ai.hackclub.com", { modelsUrl: "https://ai.hackclub.com/proxy/v1/models" }), | |
| huggingface: openAi("https://router.huggingface.co/v1", "https://huggingface.co/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://huggingface.co/docs/inference-providers/index", modelsUrl: "https://router.huggingface.co/v1/models" }), | |
| iflytek: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/Web.html" }), | |
| liquid: openAi("https://inference.liquid.ai/v1", "https://playground.liquid.ai", { modelsUrl: "https://inference.liquid.ai/v1/models" }), | |
| mistral: openAi("https://api.mistral.ai/v1", "https://console.mistral.ai/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.mistral.ai/api/endpoint/models" }), | |
| morph: openAi("https://api.morphllm.com/v1", "https://morphllm.com", { verification: "official", documentationUrl: "https://docs.morphllm.com/api-reference/introduction" }), | |
| "ollama-cloud": openAi("https://ollama.com/v1", "https://ollama.com/settings/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ollama.com/cloud", modelsUrl: "https://ollama.com/api/tags" }), | |
| opencode: openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/", modelsUrl: "https://opencode.ai/zen/v1/models" }), | |
| openrouter: openAi("https://openrouter.ai/api/v1", "https://openrouter.ai/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://openrouter.ai/docs/api/api-reference/models/get-models" }), | |
| ovhcloud: openAi("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", "https://console.ovhcloud.com", { keyOptional: true, modelsUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models" }), | |
| pollinations: openAi("https://gen.pollinations.ai/v1", "https://pollinations.ai", { keyOptional: true }), | |
| reka: openAi("https://api.reka.ai/v1", "https://platform.reka.ai"), | |
| sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/cloud/docs/api-reference/models" }), | |
| sparkdesk: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/Web.html" }), | |
| agnes: openAi("https://apihub.agnes-ai.com/v1", "https://agnes-ai.com"), | |
| ainative: openAi("https://api.ainative.studio/api/v1", "https://ainative.studio", { modelsUrl: "https://api.ainative.studio/api/v1/models" }), | |
| aion: openAi("https://api.aionlabs.ai/v1", "https://aionlabs.ai", { modelsUrl: "https://api.aionlabs.ai/v1/models" }), | |
| baidu: openAi("https://qianfan.baidubce.com/v2", "https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application", { verification: "official", documentationUrl: "https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Fm2vrveyu", discovery: "static", liveModels: false, models: ["ernie-5.1", "ernie-5.0", "ernie-4.5-turbo-128k"] }), | |
| glm: openAi("https://api.z.ai/api/coding/paas/v4", "https://z.ai/manage-apikey/apikey-list", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.z.ai/guides/overview/quick-start", discovery: "static", liveModels: false, models: ["glm-5"] }), | |
| "glm-cn": openAi("https://open.bigmodel.cn/api/coding/paas/v4", "https://open.bigmodel.cn/usercenter/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.bigmodel.cn/cn/api/introduction", discovery: "static", liveModels: false, models: ["glm-4.5-flash"] }), | |
| "kilo-gateway": openAi("https://api.kilo.ai/api/gateway", "https://kilo.ai", { modelsUrl: "https://api.kilo.ai/api/gateway/models" }), | |
| "opencode-zen": openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/" }), | |
| requesty: openAi("https://router.requesty.ai/v1", "https://app.requesty.ai", { modelsUrl: "https://router.requesty.ai/v1/models" }), | |
| routeway: openAi("https://api.routeway.ai/v1", "https://routeway.ai", { modelsUrl: "https://api.routeway.ai/v1/models" }), | |
| sealion: openAi("https://api.sea-lion.ai/v1", "https://sea-lion.ai", { discovery: "static", liveModels: false, models: ["aisingapore/Llama-SEA-LION-v3.5-70B-R", "aisingapore/Llama-SEA-LION-v3-70B-IT", "aisingapore/Gemma-SEA-LION-v4-27B-IT", "aisingapore/Qwen-SEA-LION-v4.5-27B-IT", "aisingapore/Qwen-SEA-LION-v4-32B-IT"] }), | |
| siliconflow: openAi("https://api.siliconflow.cn/v1", "https://cloud.siliconflow.cn/account/ak", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.siliconflow.cn/en/api-reference/models/get-model-list" }), | |
| tencent: openAi("https://api.hunyuan.cloud.tencent.com/v1", "https://console.cloud.tencent.com/hunyuan/api-key", { verification: "official", documentationUrl: "https://cloud.tencent.com/document/product/1729/111007", discovery: "static", liveModels: false, models: ["hunyuan-turbos-latest", "hunyuan-t1-latest", "hunyuan-pro", "hunyuan-lite"] }), | |
| bytez: openAi("https://api.bytez.com/models/v2/openai/v1", "https://bytez.com", { verification: "unverified", documentationUrl: "https://docs.bytez.com/", discovery: "static", liveModels: false, models: ["meta-llama/Llama-3.3-70B-Instruct", "mistralai/Mistral-7B-Instruct-v0.3", "Qwen/Qwen2.5-72B-Instruct"], note: "The recurring-credit classification is retained from the requested catalog, but the current reset terms could not be independently verified." }), | |
| "nous-research": openAi("https://inference-api.nousresearch.com/v1", "https://portal.nousresearch.com", { discovery: "static", liveModels: false, models: ["Hermes-4-405B", "Hermes-4-70B"] }), | |
| agentrouter: { baseUrl: "https://agentrouter.org", dashboardUrl: "https://agentrouter.org", adapter: "anthropic", authKind: "key", supportLevel: "experimental", verification: "primary", modelsUrl: "https://agentrouter.org/v1/models", discovery: "live", liveModels: true }, | |
| ai21: openAi("https://api.ai21.com/studio/v1", "https://studio.ai21.com/account/api-key", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ai21.com/reference/models" }), | |
| baichuan: openAi("https://api.baichuan-ai.com/v1", "https://platform.baichuan-ai.com/console/apikey", { verification: "official" }), | |
| deepinfra: openAi("https://api.deepinfra.com/v1/openai", "https://deepinfra.com/dash/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://deepinfra.com/docs/openai_api" }), | |
| deepseek: openAi("https://api.deepseek.com", "https://platform.deepseek.com/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://api-docs.deepseek.com/api/list-models" }), | |
| doubao: openAi("https://ark.cn-beijing.volces.com/api/v3", "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", { verification: "official" }), | |
| fireworks: openAi("https://api.fireworks.ai/inference/v1", "https://fireworks.ai/account/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.fireworks.ai/api-reference/list-models", modelsUrl: "https://api.fireworks.ai/v1/accounts/fireworks/models?filter=supports_serverless=true" }), | |
| "freemodel-dev": openAi("https://api.freemodel.dev/v1", "https://freemodel.dev", { modelsUrl: "https://api.freemodel.dev/v1/models" }), | |
| hyperbolic: openAi("https://api.hyperbolic.xyz/v1", "https://app.hyperbolic.xyz/settings", { verification: "official" }), | |
| longcat: openAi("https://api.longcat.chat/openai/v1", "https://longcat.chat", { verification: "official", discovery: "static", liveModels: false, models: ["LongCat-2.0"] }), | |
| monsterapi: openAi("https://api.monsterapi.ai/v1", "https://monsterapi.ai", { verification: "official" }), | |
| nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://studio.nebius.com", { verification: "official" }), | |
| novita: openAi("https://api.novita.ai/openai/v1", "https://novita.ai/settings/key-management", { supportLevel: "supported", verification: "official", modelsUrl: "https://api.novita.ai/openai/v1/models" }), | |
| nscale: openAi("https://inference.api.nscale.com/v1", "https://console.nscale.com", { verification: "official" }), | |
| nvidia: openAi("https://integrate.api.nvidia.com/v1", "https://build.nvidia.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.api.nvidia.com/nim/reference/llm-apis" }), | |
| publicai: openAi("https://api.publicai.co/v1", "https://publicai.co"), | |
| scaleway: openAi("https://api.scaleway.ai/v1", "https://console.scaleway.com/generative-api", { verification: "official" }), | |
| sensenova: openAi("https://token.sensenova.cn/v1", "https://console.sensenova.cn", { verification: "official" }), | |
| stepfun: openAi("https://api.stepfun.com/v1", "https://platform.stepfun.com", { verification: "official" }), | |
| together: openAi("https://api.together.xyz/v1", "https://api.together.xyz/settings/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.together.ai/reference/models-1" }), | |
| vertex: { baseUrl: "https://aiplatform.googleapis.com", dashboardUrl: "https://console.cloud.google.com/vertex-ai", adapter: "google", authKind: "key", supportLevel: "experimental", verification: "official", documentationUrl: "https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference", discovery: "static", liveModels: false, googleMode: "vertex", models: ["gemini-3.1-pro-preview", "gemini-3.1-flash-lite", "gemini-3-flash-preview"], note: "Vertex accepts an express-mode API key here. ADC users can instead configure project and location through the existing Google Vertex provider." }, | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/providers/free-directory.ts` around lines 65 - 126, Constrain the keys of
CONNECTABLE to the union of provider access-group IDs derived from the existing
group arrays, rather than using Record<string, ConnectableOverride>. Preserve
the current entries and ConnectableOverride value typing so any future
misspelled or unknown provider key fails at compile time.
| const suppliedBaseUrl = supplied.baseUrl?.trim(); | ||
| let matchesResolvedTemplate = false; | ||
| if (suppliedBaseUrl && entry.baseUrl.includes("{")) { | ||
| try { | ||
| const marker = "opencodex-template-value"; | ||
| const templateUrl = new URL(entry.baseUrl.replace(/\{[^}]+\}/g, marker)); | ||
| const candidateUrl = new URL(suppliedBaseUrl); | ||
| const escapedPath = templateUrl.pathname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| const pathPattern = new RegExp(`^${escapedPath.replaceAll(marker, "[A-Za-z0-9_-]+")}/?$`); | ||
| matchesResolvedTemplate = candidateUrl.origin === templateUrl.origin | ||
| && !candidateUrl.search | ||
| && !candidateUrl.hash | ||
| && pathPattern.test(candidateUrl.pathname); | ||
| } catch { | ||
| matchesResolvedTemplate = false; | ||
| } | ||
| } | ||
| const normalizedSuppliedBaseUrl = suppliedBaseUrl?.replace(/\/+$/, ""); | ||
| const matchesOfficialChoice = !!normalizedSuppliedBaseUrl && !!entry.baseUrlChoices?.some(choice => | ||
| choice.baseUrl?.replace(/\/+$/, "") === normalizedSuppliedBaseUrl | ||
| ); | ||
| const acceptsResolvedBaseUrl = entry.allowBaseUrlOverride === true || matchesResolvedTemplate || matchesOfficialChoice; | ||
| if (suppliedBaseUrl && suppliedBaseUrl.replace(/\/+$/, "") !== entry.baseUrl.replace(/\/+$/, "") && !acceptsResolvedBaseUrl) { | ||
| return jsonResponse({ error: "provider baseUrl does not match the trusted preset" }, 400); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Templated preset baseUrl can pass validation unresolved when provider.baseUrl is omitted or echoed back verbatim.
For a preset whose registry baseUrl contains a placeholder (currently only cloudflare-ai, e.g. https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1), the template-resolution check only runs if (suppliedBaseUrl && entry.baseUrl.includes("{")) (Line 426). Two gaps:
- If the caller omits
provider.baseUrlentirely,suppliedBaseUrlis falsy, so the whole block is skipped, and the guard on Line 446 (if (suppliedBaseUrl && ...)) never fires either —provider.baseUrlthen falls through to the raw, unresolvedentry.baseUrl(literal{account_id}still in it) viaproviderConfigSeed(entry)(Line 450). - Even if the caller supplies
suppliedBaseUrlequal to the literal unresolved template,suppliedBaseUrl.replace(...) === entry.baseUrl.replace(...)istrue(identical strings), so Line 446's check treats it as "no override needed" and accepts it as-is.
Today this is masked because cloudflare-ai has discovery: "static" (Line 464-468 returns the static catalog before any fetch happens), so the unresolved URL never actually gets dispatched. But the validation itself doesn't enforce "a templated preset's baseUrl must be resolved," so the very first templated preset that ships with discovery: "live" would silently attempt a live fetch against a URL containing literal {account_id} instead of failing fast with a clear error.
🐛 Proposed fix: require resolution when the preset's baseUrl is templated
const supplied = stripCodexRuntimeProviderFields(body.provider as OcxProviderConfig);
const suppliedBaseUrl = supplied.baseUrl?.trim();
+ if (entry.baseUrl.includes("{")
+ && (!suppliedBaseUrl || suppliedBaseUrl.replace(/\/+$/, "") === entry.baseUrl.replace(/\/+$/, ""))) {
+ return jsonResponse({ error: "provider baseUrl must resolve the preset's templated placeholder" }, 400);
+ }
let matchesResolvedTemplate = false;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const suppliedBaseUrl = supplied.baseUrl?.trim(); | |
| let matchesResolvedTemplate = false; | |
| if (suppliedBaseUrl && entry.baseUrl.includes("{")) { | |
| try { | |
| const marker = "opencodex-template-value"; | |
| const templateUrl = new URL(entry.baseUrl.replace(/\{[^}]+\}/g, marker)); | |
| const candidateUrl = new URL(suppliedBaseUrl); | |
| const escapedPath = templateUrl.pathname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| const pathPattern = new RegExp(`^${escapedPath.replaceAll(marker, "[A-Za-z0-9_-]+")}/?$`); | |
| matchesResolvedTemplate = candidateUrl.origin === templateUrl.origin | |
| && !candidateUrl.search | |
| && !candidateUrl.hash | |
| && pathPattern.test(candidateUrl.pathname); | |
| } catch { | |
| matchesResolvedTemplate = false; | |
| } | |
| } | |
| const normalizedSuppliedBaseUrl = suppliedBaseUrl?.replace(/\/+$/, ""); | |
| const matchesOfficialChoice = !!normalizedSuppliedBaseUrl && !!entry.baseUrlChoices?.some(choice => | |
| choice.baseUrl?.replace(/\/+$/, "") === normalizedSuppliedBaseUrl | |
| ); | |
| const acceptsResolvedBaseUrl = entry.allowBaseUrlOverride === true || matchesResolvedTemplate || matchesOfficialChoice; | |
| if (suppliedBaseUrl && suppliedBaseUrl.replace(/\/+$/, "") !== entry.baseUrl.replace(/\/+$/, "") && !acceptsResolvedBaseUrl) { | |
| return jsonResponse({ error: "provider baseUrl does not match the trusted preset" }, 400); | |
| } | |
| const supplied = stripCodexRuntimeProviderFields(body.provider as OcxProviderConfig); | |
| const suppliedBaseUrl = supplied.baseUrl?.trim(); | |
| if (entry.baseUrl.includes("{") | |
| && (!suppliedBaseUrl || suppliedBaseUrl.replace(/\/+$/, "") === entry.baseUrl.replace(/\/+$/, ""))) { | |
| return jsonResponse({ error: "provider baseUrl must resolve the preset's templated placeholder" }, 400); | |
| } | |
| let matchesResolvedTemplate = false; | |
| if (suppliedBaseUrl && entry.baseUrl.includes("{")) { | |
| try { | |
| const marker = "opencodex-template-value"; | |
| const templateUrl = new URL(entry.baseUrl.replace(/\{[^}]+\}/g, marker)); | |
| const candidateUrl = new URL(suppliedBaseUrl); | |
| const escapedPath = templateUrl.pathname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| const pathPattern = new RegExp(`^${escapedPath.replaceAll(marker, "[A-Za-z0-9_-]+")}/?$`); | |
| matchesResolvedTemplate = candidateUrl.origin === templateUrl.origin | |
| && !candidateUrl.search | |
| && !candidateUrl.hash | |
| && pathPattern.test(candidateUrl.pathname); | |
| } catch { | |
| matchesResolvedTemplate = false; | |
| } | |
| } | |
| const normalizedSuppliedBaseUrl = suppliedBaseUrl?.replace(/\/+$/, ""); | |
| const matchesOfficialChoice = !!normalizedSuppliedBaseUrl && !!entry.baseUrlChoices?.some(choice => | |
| choice.baseUrl?.replace(/\/+$/, "") === normalizedSuppliedBaseUrl | |
| ); | |
| const acceptsResolvedBaseUrl = entry.allowBaseUrlOverride === true || matchesResolvedTemplate || matchesOfficialChoice; | |
| if (suppliedBaseUrl && suppliedBaseUrl.replace(/\/+$/, "") !== entry.baseUrl.replace(/\/+$/, "") && !acceptsResolvedBaseUrl) { | |
| return jsonResponse({ error: "provider baseUrl does not match the trusted preset" }, 400); | |
| } |
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] 431-431: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(^${escapedPath.replaceAll(marker, "[A-Za-z0-9_-]+")}/?$)
Note: [CWE-1333] Inefficient Regular Expression Complexity
(regexp-from-variable)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/server/management/provider-routes.ts` around lines 424 - 448, Update the
base URL validation around suppliedBaseUrl, matchesResolvedTemplate, and
acceptsResolvedBaseUrl so presets with templated entry.baseUrl always require a
concrete resolved URL. Reject omitted baseUrl values and values that still equal
the unresolved template, while preserving valid resolved-template,
official-choice, and explicitly allowed overrides; ensure providerConfigSeed
never receives a literal placeholder URL.
Summary
Dependency
Merge after #405. The provider-directory commits disappear from this diff after that PR lands.
Tests
Summary by CodeRabbit
New Features
Improvements