Skip to content
Open
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
50 changes: 49 additions & 1 deletion src/model/catalog/providers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,31 @@
import type { ProviderCatalog } from "./types.js";
import type { CatalogModelEntry, ProviderCatalog } from "./types.js";

function codexCatalogModel(
displayName: string,
maxContextTokens = 272_000,
): CatalogModelEntry {
return {
displayName,
capabilities: {
supportsToolUse: true,
supportsStreaming: true,
supportsParallelToolCalls: true,
supportsThinking: true,
supportsJsonSchema: true,
supportsSystemPrompt: true,
supportsPromptCache: true,
maxContextTokens,
maxOutputTokens: 128_000,
},
multimodal: {
input: ["text", "image"],
maxImagesPerRequest: 20,
supportedImageMimeTypes: ["image/jpeg", "image/png", "image/gif", "image/webp"],
imageDetail: "auto",
},
aliases: [],
};
}

export const PROVIDER_CATALOG: ProviderCatalog = {

Expand Down Expand Up @@ -118,6 +145,27 @@ export const PROVIDER_CATALOG: ProviderCatalog = {
},
},

// ── Codex (ChatGPT subscription) ─────────────────────────────────────

codex: {
displayName: "Codex (ChatGPT subscription)",
protocol: "openai-responses",
defaultUrl: "https://chatgpt.com/backend-api/codex",
models: {
"gpt-5.6-sol": codexCatalogModel("GPT-5.6 Sol"),
"gpt-5.6-sol-pro": codexCatalogModel("GPT-5.6 Sol Pro"),
"gpt-5.6-terra": codexCatalogModel("GPT-5.6 Terra"),
"gpt-5.6-terra-pro": codexCatalogModel("GPT-5.6 Terra Pro"),
"gpt-5.6-luna": codexCatalogModel("GPT-5.6 Luna"),
"gpt-5.6-luna-pro": codexCatalogModel("GPT-5.6 Luna Pro"),
"gpt-5.5": codexCatalogModel("GPT-5.5"),
"gpt-5.4-mini": codexCatalogModel("GPT-5.4 Mini"),
"gpt-5.4": codexCatalogModel("GPT-5.4"),
"gpt-5.3-codex": codexCatalogModel("GPT-5.3 Codex"),
"gpt-5.3-codex-spark": codexCatalogModel("GPT-5.3 Codex Spark", 128_000),
},
},

// ── OpenAI ─────────────────────────────────────────────────────────────

openai: {
Expand Down
21 changes: 19 additions & 2 deletions src/model/config/parseModelConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "../protocol/multimodal.js";
import { lookupCatalogModel, lookupCatalogProvider } from "../catalog/index.js";
import { resolveApiKey, type CredentialEnv } from "./resolveCredentials.js";
import { CODEX_BASE_URL } from "../providers/codex/constants.js";
import {
isModelProtocol,
isRecord,
Expand Down Expand Up @@ -79,6 +80,19 @@ function parseProvider(providerId: string, rawProvider: unknown, env?: Credentia
throw new ModelConfigError("invalid_config_value", `Provider ${providerId} requires a url.`, { providerId });
}
assertValidUrl(rawUrl, providerId);
if (
providerId === "codex"
&& (
protocol !== "openai-responses"
|| rawUrl.replace(/\/+$/, "") !== CODEX_BASE_URL
)
) {
throw new ModelConfigError(
"invalid_config_value",
`Provider codex must use protocol openai-responses and URL ${CODEX_BASE_URL}.`,
{ providerId, protocol, url: rawUrl },
);
}

if (!isRecord(provider.models) || Object.keys(provider.models).length === 0) {
throw new ModelConfigError("empty_models", `Provider ${providerId} must contain at least one model.`, {
Expand Down Expand Up @@ -110,10 +124,13 @@ function resolveProviderApiKey(
env?: CredentialEnv,
catalogEnvVar?: string,
): string {
if (providerId === "ollama" && value === undefined) {
return "ollama";
if ((providerId === "ollama" || providerId === "codex") && value === undefined) {
return providerId === "ollama" ? "ollama" : "";
}
const hasBlankString = typeof value === "string" && value.trim().length === 0;
if (providerId === "codex" && hasBlankString) {
return "";
}
const hasConfigValue = value !== undefined && value !== null && !hasBlankString;
const effectiveValue = hasConfigValue
? value
Expand Down
16 changes: 15 additions & 1 deletion src/model/protocol/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export type CanonicalThinkingBlock = {
signature?: string;
/** Provider-native reasoning_content that should be replayed when present. */
reasoningContent?: string;
/** OpenAI Responses native reasoning item identifier, preserved for Codex replay. */
responsesItemId?: string;
/** Opaque OpenAI Responses encrypted reasoning payload, preserved for Codex replay. */
encryptedReasoningContent?: string;
};

export type CanonicalImageBlock = {
Expand Down Expand Up @@ -55,6 +59,8 @@ export type CanonicalToolCall = {
id: string;
name: string;
input: unknown;
/** OpenAI Responses native function_call item identifier, preserved for Codex replay. */
responsesItemId?: string;
raw?: unknown;
};

Expand Down Expand Up @@ -260,7 +266,15 @@ export type CanonicalModelEvent =
}
| { type: "message_start"; role: "assistant"; raw?: unknown }
| { type: "text_delta"; text: string; raw?: unknown }
| { type: "thinking_delta"; text: string; signature?: string; reasoningContent?: string; raw?: unknown }
| {
type: "thinking_delta";
text: string;
signature?: string;
reasoningContent?: string;
responsesItemId?: string;
encryptedReasoningContent?: string;
raw?: unknown;
}
| { type: "tool_call_start"; id: string; name: string; raw?: unknown }
| { type: "tool_call_delta"; id: string; delta: string; raw?: unknown }
| { type: "tool_call_end"; toolCall: CanonicalToolCall; wasRepaired?: boolean; raw?: unknown }
Expand Down
56 changes: 40 additions & 16 deletions src/model/providerEndpoint.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { ProviderConfig } from "./protocol/canonical.js";
import { CODEX_PROVIDER_ID } from "./providers/codex/constants.js";

export type ProviderEndpointProtocol = "openai" | "openai-responses" | "anthropic" | "google";

const VERSION_SEGMENT_PATTERN = /^v\d+(?:beta\d*)?$/i;
Expand Down Expand Up @@ -79,26 +82,46 @@ export function normalizeGoogleProbeModel(model: string): string {
return withoutProvider;
}

export function buildProviderChatEndpoint(input: {
export type ProviderEndpointInput = {
protocol: ProviderEndpointProtocol;
baseUrl: string;
providerId?: string;
model?: string;
googleMethod?: string;
}): string {
};

export function isCodexSubscriptionProvider(
provider: Pick<ProviderConfig, "id" | "protocol" | "url">,
): boolean {
if (provider.id.toLowerCase() !== CODEX_PROVIDER_ID) return false;
if (provider.protocol !== "openai-responses") return false;
try {
const url = new URL(provider.url);
return url.protocol === "https:"
&& url.hostname.toLowerCase() === "chatgpt.com"
&& url.pathname.replace(/\/+$/, "") === "/backend-api/codex";
} catch {
return false;
}
}

export function buildProviderChatEndpoint(input: ProviderEndpointInput): string {
return buildProviderChatEndpointCandidates(input)[0] || "";
}

export function buildProviderChatEndpointCandidates(input: {
protocol: ProviderEndpointProtocol;
baseUrl: string;
model?: string;
googleMethod?: string;
}): string[] {
export function buildProviderChatEndpointCandidates(input: ProviderEndpointInput): string[] {
const normalizedProtocol = input.protocol;
if (normalizedProtocol === "anthropic") {
return buildEndpointCandidates(input.baseUrl, "v1", "messages");
}
if (normalizedProtocol === "openai-responses") {
if (isCodexSubscriptionProvider({
id: input.providerId || "",
protocol: input.protocol,
url: input.baseUrl,
})) {
return [joinUrl(input.baseUrl, "responses")];
}
return buildEndpointCandidates(input.baseUrl, "v1", "responses");
}
if (normalizedProtocol === "google") {
Expand All @@ -110,20 +133,21 @@ export function buildProviderChatEndpointCandidates(input: {
return buildEndpointCandidates(input.baseUrl, "v1", "chat/completions");
}

export function buildProviderModelsEndpoint(input: {
protocol: ProviderEndpointProtocol;
baseUrl: string;
}): string {
export function buildProviderModelsEndpoint(input: ProviderEndpointInput): string {
return buildProviderModelsEndpointCandidates(input)[0] || "";
}

export function buildProviderModelsEndpointCandidates(input: {
protocol: ProviderEndpointProtocol;
baseUrl: string;
}): string[] {
export function buildProviderModelsEndpointCandidates(input: ProviderEndpointInput): string[] {
if (input.protocol === "google") {
return buildEndpointCandidates(input.baseUrl, "v1beta", "models");
}
if (isCodexSubscriptionProvider({
id: input.providerId || "",
protocol: input.protocol,
url: input.baseUrl,
})) {
return [joinUrl(input.baseUrl, "models")];
}
return buildEndpointCandidates(input.baseUrl, "v1", "models");
}

Expand Down
Loading