Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 52 additions & 14 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,53 @@ export type ProviderModelsApiItem = {
};
};

export function isProviderModelsApiItems(value: unknown): value is ProviderModelsApiItem[] {
return Array.isArray(value) && value.every(item =>
item !== null
&& typeof item === "object"
&& !Array.isArray(item)
&& typeof (item as { id?: unknown }).id === "string"
&& (item as { id: string }).id.trim().length > 0
);
/** Normalize the common model-catalog envelopes without weakening malformed-response fallback. */
export function parseProviderModelsApiItems(value: unknown, allowModelsEnvelope = false): ProviderModelsApiItem[] | null {
const rows = Array.isArray(value)
? value
: value !== null && typeof value === "object"
? (Array.isArray((value as { data?: unknown }).data)
? (value as { data: unknown[] }).data
: allowModelsEnvelope && Array.isArray((value as { models?: unknown }).models)
? (value as { models: unknown[] }).models
: null)
: null;
if (!rows) return null;

const normalized: ProviderModelsApiItem[] = [];
for (const row of rows) {
if (typeof row === "string" && row.trim()) {
normalized.push({ id: row.trim() });
continue;
}
if (row === null || typeof row !== "object" || Array.isArray(row)) return null;
const item = row as Record<string, unknown>;
const rawId = typeof item.id === "string" ? item.id
: typeof item.name === "string" ? item.name
: typeof item.model === "string" ? item.model
Comment on lines +87 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 id = rawId.trim()
.replace(/^models\//, "")
.replace(/^accounts\/fireworks\/models\//, "");
if (!id) return null;
const contextLength = [
item.context_length,
item.context_window,
item.contextWindow,
item.inputTokenLimit,
item.max_context_length,
item.max_model_len,
].find(candidate => typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) as number | undefined;
normalized.push({
id,
...(typeof item.owned_by === "string" ? { owned_by: item.owned_by } : {}),
...(contextLength ? { context_length: contextLength } : {}),
...(item.metadata !== null && typeof item.metadata === "object" && !Array.isArray(item.metadata)
? { metadata: item.metadata as ProviderModelsApiItem["metadata"] }
: {}),
});
}
return normalized;
}

export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined {
Expand Down Expand Up @@ -326,7 +365,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
return models;
}

const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
const res = await fetch(url, { headers, redirect: "error", signal: AbortSignal.timeout(8000) });
if (!res.ok) {
const { models, fallback } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
console.warn(
Expand All @@ -352,17 +391,16 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
);
return models;
}
const data = json !== null && typeof json === "object" && !Array.isArray(json)
? (json as { data?: unknown }).data
: undefined;
if (!isProviderModelsApiItems(data)) {
const allowsModelsEnvelope = (prov.adapter === "google" && (prov.googleMode ?? "ai-studio") === "ai-studio")
|| new URL(url).pathname.replace(/\/+$/, "").endsWith("/api/tags");
Comment on lines +394 to +395

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 items = parseProviderModelsApiItems(json, allowsModelsEnvelope);
if (!items) {
const { models, fallback } = failedDiscoveryFallback({ reason: "invalid_response" });
console.warn(
`[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
);
return models;
}
const items = data;
const live = items.map(m => applyProviderConfigHints(name, prov, {
id: m.id,
provider: name,
Expand Down
11 changes: 7 additions & 4 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"
import { loginCursor, refreshCursorToken } from "./cursor";
import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
import { effectiveGoogleMode } from "../providers/registry";
import { effectiveGoogleMode, getProviderRegistryEntry } from "../providers/registry";
import { resolveProviderTransport } from "../providers/xai-transport";
import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";

Expand Down Expand Up @@ -436,13 +436,16 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und
providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined,
);
const headers: Record<string, string> = { ...(effectiveProvider.headers ?? {}) };
const registry = getProviderRegistryEntry(providerName);
const sameRegistryEndpoint = registry?.baseUrl.replace(/\/+$/, "") === effectiveProvider.baseUrl.replace(/\/+$/, "");
const trustedModelsUrl = sameRegistryEndpoint ? registry?.modelsUrl : undefined;
if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") {
// Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
// models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 —
// enough to list everything without a pageToken loop. Vertex/antigravity keep the
// generic branch (they fall back to their static model lists).
if (apiKey) headers["x-goog-api-key"] = apiKey;
return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers };
return { url: trustedModelsUrl ?? `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers };
}
if (effectiveProvider.adapter === "anthropic") {
headers["anthropic-version"] = "2023-06-01";
Expand All @@ -452,10 +455,10 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und
} else if (apiKey) {
headers["x-api-key"] = apiKey;
}
return { url: `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers };
return { url: trustedModelsUrl ?? `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers };
}
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
return { url: `${effectiveProvider.baseUrl}/models`, headers };
return { url: trustedModelsUrl ?? `${effectiveProvider.baseUrl}/models`, headers };
}

/**
Expand Down
28 changes: 25 additions & 3 deletions src/providers/derive.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CodexAccountMode, OcxProviderConfig } from "../types";
import { PROVIDER_REGISTRY, type ProviderRegistryEntry } from "./registry";
import type { ProviderAccessGroup } from "./free-directory";

export interface DerivedKeyLoginProvider {
label: string;
Expand Down Expand Up @@ -67,6 +68,15 @@ export interface DerivedProviderPreset {
baseUrlChoices?: Array<{ id: string; label: string; baseUrl?: string }>;
/** Immutable canonical provider config seed for the reserved canonical `openai` forward preset. */
provider?: OcxProviderConfig;
accessGroups?: readonly ProviderAccessGroup[];
supportLevel?: "supported" | "experimental" | "reference";
verification?: "official" | "primary" | "unverified";
documentationUrl?: string;
modelsUrl?: string;
discovery?: "live" | "static" | "hybrid" | "unsupported";
lastVerified?: string;
models?: string[];
liveModels?: boolean;
}

export function listRegistryEntries(): readonly ProviderRegistryEntry[] {
Expand Down Expand Up @@ -125,7 +135,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
const out: Record<string, DerivedKeyLoginProvider> = {};
for (const entry of PROVIDER_REGISTRY) {
if (entry.authKind !== "key") continue;
if (entry.authKind !== "key" || entry.supportLevel === "reference" || entry.directoryOnly) continue;
if (!entry.dashboardUrl) throw new Error(`Registry key provider missing dashboardUrl: ${entry.id}`);
out[entry.id] = {
label: entry.label,
Expand Down Expand Up @@ -165,7 +175,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
}

export function deriveInitProviders(): DerivedInitProvider[] {
return PROVIDER_REGISTRY.map(entry => ({
return PROVIDER_REGISTRY.filter(entry => entry.supportLevel !== "reference" && !entry.directoryOnly).map(entry => ({
id: entry.id,
label: formatInitLabel(entry),
adapter: entry.adapter,
Expand All @@ -192,7 +202,7 @@ export function deriveOAuthIds(): string[] {

export function deriveProviderPresets(): DerivedProviderPreset[] {
const presets = PROVIDER_REGISTRY
.filter(entry => entry.featured || entry.authKind === "key" || entry.dashboardPreset)
.filter(entry => entry.featured || entry.authKind === "key" || entry.dashboardPreset || entry.accessGroups?.length)
.map(entryToPreset);
return [...dedupePresets(presets), customPreset()];
}
Expand Down Expand Up @@ -231,6 +241,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
if (prov.freeTier === undefined && seed.freeTier !== undefined) prov.freeTier = seed.freeTier;
if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip;
if (!prov.headers && seed.headers) prov.headers = { ...seed.headers };
if (prov.googleMode === undefined && seed.googleMode !== undefined) prov.googleMode = seed.googleMode;
if (prov.project === undefined && seed.project !== undefined) prov.project = seed.project;
if (prov.location === undefined && seed.location !== undefined) prov.location = seed.location;
}

export function deriveFeaturedProviderIds(): string[] {
Expand Down Expand Up @@ -270,6 +283,15 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset {
...(entry.keyOptional ? { keyOptional: true } : {}),
...(entry.freeTier ? { freeTier: true } : {}),
...(entry.baseUrlChoices ? { baseUrlChoices: entry.baseUrlChoices.map(c => ({ ...c })) } : {}),
...(entry.accessGroups ? { accessGroups: [...entry.accessGroups] } : {}),
...(entry.supportLevel ? { supportLevel: entry.supportLevel } : {}),
...(entry.verification ? { verification: entry.verification } : {}),
...(entry.documentationUrl ? { documentationUrl: entry.documentationUrl } : {}),
...(entry.modelsUrl ? { modelsUrl: entry.modelsUrl } : {}),
...(entry.discovery ? { discovery: entry.discovery } : {}),
...(entry.lastVerified ? { lastVerified: entry.lastVerified } : {}),
...(entry.models ? { models: [...entry.models] } : {}),
...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}),
};
}

Expand Down
Loading
Loading