diff --git a/.changeset/eight-pots-shout.md b/.changeset/eight-pots-shout.md new file mode 100644 index 00000000000..72ea3cadbca --- /dev/null +++ b/.changeset/eight-pots-shout.md @@ -0,0 +1,8 @@ +--- +"kilo-code": minor +--- + +Add support for OpenAI Codex subscriptions (thanks Roo) + +- Fix: Reset invalid model selection when using OpenAI Codex provider (PR #10777 by @hannesrudolph) +- Add OpenAI - ChatGPT Plus/Pro Provider that gives subscription-based access to Codex models without per-token costs (PR #10736 by @hannesrudolph) diff --git a/cli/src/constants/providers/labels.ts b/cli/src/constants/providers/labels.ts index 27e0b73e245..ed69536f288 100644 --- a/cli/src/constants/providers/labels.ts +++ b/cli/src/constants/providers/labels.ts @@ -8,6 +8,7 @@ export const PROVIDER_LABELS: Record = { kilocode: "Kilo Code", anthropic: "Anthropic", "openai-native": "OpenAI", + "openai-codex": "OpenAI - ChatGPT Plus/Pro", openrouter: "OpenRouter", bedrock: "Amazon Bedrock", gemini: "Google Gemini", diff --git a/cli/src/constants/providers/models.ts b/cli/src/constants/providers/models.ts index 3fa5c0e85c1..fd97bbb13d0 100644 --- a/cli/src/constants/providers/models.ts +++ b/cli/src/constants/providers/models.ts @@ -141,6 +141,7 @@ export const PROVIDER_TO_ROUTER_NAME: Record = "vscode-lm": null, gemini: null, "openai-native": null, + "openai-codex": null, mistral: null, moonshot: null, deepseek: null, @@ -193,6 +194,7 @@ export const PROVIDER_MODEL_FIELD: Record = { "vscode-lm": "vsCodeLmModelSelector", gemini: null, "openai-native": null, + "openai-codex": null, mistral: null, moonshot: null, deepseek: null, diff --git a/cli/src/constants/providers/settings.ts b/cli/src/constants/providers/settings.ts index 668b55fc84b..8029adce74e 100644 --- a/cli/src/constants/providers/settings.ts +++ b/cli/src/constants/providers/settings.ts @@ -810,6 +810,9 @@ export const getProviderSettings = (provider: ProviderName, config: ProviderSett createFieldConfig("openAiNativeBaseUrl", config, "Default"), ] + case "openai-codex": + return [createFieldConfig("apiModelId", config, "gpt-4o")] + case "bedrock": return [ createFieldConfig("awsAccessKey", config), @@ -1055,6 +1058,7 @@ export const PROVIDER_DEFAULT_MODELS: Record = { kilocode: "anthropic/claude-sonnet-4", anthropic: "claude-3-5-sonnet-20241022", "openai-native": "gpt-4o", + "openai-codex": "gpt-4o", openrouter: "anthropic/claude-3-5-sonnet", bedrock: "anthropic.claude-3-5-sonnet-20241022-v2:0", gemini: "gemini-1.5-pro-latest", diff --git a/cli/src/constants/providers/validation.ts b/cli/src/constants/providers/validation.ts index 1a4ad9f3ba5..b210acada70 100644 --- a/cli/src/constants/providers/validation.ts +++ b/cli/src/constants/providers/validation.ts @@ -8,6 +8,7 @@ export const PROVIDER_REQUIRED_FIELDS: Record = { kilocode: ["kilocodeToken", "kilocodeModel"], anthropic: ["apiKey", "apiModelId"], "openai-native": ["openAiNativeApiKey", "apiModelId"], + "openai-codex": ["apiModelId"], openrouter: ["openRouterApiKey", "openRouterModelId"], ollama: ["ollamaBaseUrl", "ollamaModelId"], lmstudio: ["lmStudioBaseUrl", "lmStudioModelId"], diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 9ecdee47474..666ebb55990 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -21,6 +21,7 @@ import { ioIntelligenceModels, mistralModels, moonshotModels, + openAiCodexModels, openAiNativeModels, qwenCodeModels, sambaNovaModels, @@ -147,6 +148,7 @@ export const providerNames = [ "mistral", "moonshot", "minimax", + "openai-codex", "openai-native", "qwen-code", "roo", @@ -341,6 +343,10 @@ const geminiCliSchema = apiModelIdProviderModelSchema.extend({ }) // kilocode_change end +const openAiCodexSchema = apiModelIdProviderModelSchema.extend({ + // No additional settings needed - uses OAuth authentication +}) + const openAiNativeSchema = apiModelIdProviderModelSchema.extend({ openAiNativeApiKey: z.string().optional(), openAiNativeBaseUrl: z.string().optional(), @@ -551,6 +557,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv vsCodeLmSchema.merge(z.object({ apiProvider: z.literal("vscode-lm") })), lmStudioSchema.merge(z.object({ apiProvider: z.literal("lmstudio") })), geminiSchema.merge(z.object({ apiProvider: z.literal("gemini") })), + openAiCodexSchema.merge(z.object({ apiProvider: z.literal("openai-codex") })), openAiNativeSchema.merge(z.object({ apiProvider: z.literal("openai-native") })), ovhcloudSchema.merge(z.object({ apiProvider: z.literal("ovhcloud") })), // kilocode_change mistralSchema.merge(z.object({ apiProvider: z.literal("mistral") })), @@ -611,6 +618,7 @@ export const providerSettingsSchema = z.object({ ...ovhcloudSchema.shape, ...inceptionSchema.shape, // kilocode_change end + ...openAiCodexSchema.shape, ...openAiNativeSchema.shape, ...mistralSchema.shape, ...deepSeekSchema.shape, @@ -704,6 +712,7 @@ export const modelIdKeysByProvider: Record = { kilocode: "kilocodeModel", bedrock: "apiModelId", vertex: "apiModelId", + "openai-codex": "apiModelId", "openai-native": "openAiModelId", ollama: "ollamaModelId", lmstudio: "lmStudioModelId", @@ -843,6 +852,11 @@ export const MODELS_BY_PROVIDER: Record< label: "MiniMax", models: Object.keys(minimaxModels), }, + "openai-codex": { + id: "openai-codex", + label: "OpenAI - ChatGPT Plus/Pro", + models: Object.keys(openAiCodexModels), + }, "openai-native": { id: "openai-native", label: "OpenAI", diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 95550d70474..24f2aa9ccf9 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -27,6 +27,7 @@ export * from "./moonshot.js" export * from "./nano-gpt.js" // kilocode_change export * from "./ollama.js" export * from "./openai.js" +export * from "./openai-codex.js" export * from "./openrouter.js" export * from "./qwen-code.js" export * from "./requesty.js" @@ -58,6 +59,7 @@ import { ioIntelligenceDefaultModelId } from "./io-intelligence.js" import { litellmDefaultModelId } from "./lite-llm.js" import { mistralDefaultModelId } from "./mistral.js" import { moonshotDefaultModelId } from "./moonshot.js" +import { openAiCodexDefaultModelId } from "./openai-codex.js" import { openRouterDefaultModelId } from "./openrouter.js" import { qwenCodeDefaultModelId } from "./qwen-code.js" import { requestyDefaultModelId } from "./requesty.js" @@ -125,6 +127,8 @@ export function getProviderDefaultModelId( return options?.isChina ? mainlandZAiDefaultModelId : internationalZAiDefaultModelId case "openai-native": return "gpt-4o" // Based on openai-native patterns + case "openai-codex": + return openAiCodexDefaultModelId case "mistral": return mistralDefaultModelId case "openai": diff --git a/packages/types/src/providers/openai-codex.ts b/packages/types/src/providers/openai-codex.ts new file mode 100644 index 00000000000..051ef4f138e --- /dev/null +++ b/packages/types/src/providers/openai-codex.ts @@ -0,0 +1,179 @@ +import type { ModelInfo } from "../model.js" + +/** + * OpenAI Codex Provider + * + * This provider uses OAuth authentication via ChatGPT Plus/Pro subscription + * instead of direct API keys. Requests are routed to the Codex backend at + * https://chatgpt.com/backend-api/codex/responses + * + * Key differences from openai-native: + * - Uses OAuth Bearer tokens instead of API keys + * - Subscription-based pricing (no per-token costs) + * - Limited model subset available + * - Custom routing to Codex backend + */ + +export type OpenAiCodexModelId = keyof typeof openAiCodexModels + +export const openAiCodexDefaultModelId: OpenAiCodexModelId = "gpt-5.2-codex" + +/** + * Models available through the Codex OAuth flow. + * These models are accessible to ChatGPT Plus/Pro subscribers. + * Costs are 0 as they are covered by the subscription. + */ +export const openAiCodexModels = { + "gpt-5.1-codex-max": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + defaultToolProtocol: "native", + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh"], + reasoningEffort: "xhigh", + // Subscription-based: no per-token costs + inputPrice: 0, + outputPrice: 0, + supportsTemperature: false, + description: "GPT-5.1 Codex Max: Maximum capability coding model via ChatGPT subscription", + }, + "gpt-5.1-codex": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + defaultToolProtocol: "native", + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + // Subscription-based: no per-token costs + inputPrice: 0, + outputPrice: 0, + supportsTemperature: false, + description: "GPT-5.1 Codex: GPT-5.1 optimized for agentic coding via ChatGPT subscription", + }, + "gpt-5.2-codex": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + defaultToolProtocol: "native", + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh"], + reasoningEffort: "medium", + inputPrice: 0, + outputPrice: 0, + supportsTemperature: false, + description: "GPT-5.2 Codex: OpenAI's flagship coding model via ChatGPT subscription", + }, + "gpt-5.1": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + defaultToolProtocol: "native", + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["none", "low", "medium", "high"], + reasoningEffort: "medium", + // Subscription-based: no per-token costs + inputPrice: 0, + outputPrice: 0, + supportsVerbosity: true, + supportsTemperature: false, + description: "GPT-5.1: General GPT-5.1 model via ChatGPT subscription", + }, + "gpt-5": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + defaultToolProtocol: "native", + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["minimal", "low", "medium", "high"], + reasoningEffort: "medium", + // Subscription-based: no per-token costs + inputPrice: 0, + outputPrice: 0, + supportsVerbosity: true, + supportsTemperature: false, + description: "GPT-5: General GPT-5 model via ChatGPT subscription", + }, + "gpt-5-codex": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + defaultToolProtocol: "native", + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + // Subscription-based: no per-token costs + inputPrice: 0, + outputPrice: 0, + supportsTemperature: false, + description: "GPT-5 Codex: GPT-5 optimized for agentic coding via ChatGPT subscription", + }, + "gpt-5-codex-mini": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + defaultToolProtocol: "native", + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + // Subscription-based: no per-token costs + inputPrice: 0, + outputPrice: 0, + supportsTemperature: false, + description: "GPT-5 Codex Mini: Faster coding model via ChatGPT subscription", + }, + "gpt-5.1-codex-mini": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + defaultToolProtocol: "native", + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + inputPrice: 0, + outputPrice: 0, + supportsTemperature: false, + description: "GPT-5.1 Codex Mini: Faster version for coding tasks via ChatGPT subscription", + }, + "gpt-5.2": { + maxTokens: 128000, + contextWindow: 400000, + supportsNativeTools: true, + defaultToolProtocol: "native", + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh"], + reasoningEffort: "medium", + inputPrice: 0, + outputPrice: 0, + supportsTemperature: false, + description: "GPT-5.2: Latest GPT model via ChatGPT subscription", + }, +} as const satisfies Record diff --git a/src/api/index.ts b/src/api/index.ts index e36a4cb3782..118f0bab4ab 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -14,6 +14,7 @@ import { VertexHandler, AnthropicVertexHandler, OpenAiHandler, + OpenAiCodexHandler, LmStudioHandler, GeminiHandler, OpenAiNativeHandler, @@ -186,6 +187,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new LmStudioHandler(options) case "gemini": return new GeminiHandler(options) + case "openai-codex": + return new OpenAiCodexHandler(options) case "openai-native": return new OpenAiNativeHandler(options) case "deepseek": diff --git a/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts new file mode 100644 index 00000000000..c7c4a48fc3d --- /dev/null +++ b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts @@ -0,0 +1,101 @@ +// cd src && npx vitest run api/providers/__tests__/openai-codex-native-tool-calls.spec.ts + +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { OpenAiCodexHandler } from "../openai-codex" +import type { ApiHandlerOptions } from "../../../shared/api" +import { NativeToolCallParser } from "../../../core/assistant-message/NativeToolCallParser" +import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth" + +describe("OpenAiCodexHandler native tool calls", () => { + let handler: OpenAiCodexHandler + let mockOptions: ApiHandlerOptions + + beforeEach(() => { + vi.restoreAllMocks() + NativeToolCallParser.clearRawChunkState() + NativeToolCallParser.clearAllStreamingToolCalls() + + mockOptions = { + apiModelId: "gpt-5.2-2025-12-11", + // minimal settings; OAuth is mocked below + } + handler = new OpenAiCodexHandler(mockOptions) + }) + + it("yields tool_call_partial chunks when API returns function_call-only response", async () => { + vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + + // Mock OpenAI SDK streaming (preferred path). + ;(handler as any).client = { + responses: { + create: vi.fn().mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + type: "response.output_item.added", + item: { + type: "function_call", + call_id: "call_1", + name: "attempt_completion", + arguments: "", + }, + output_index: 0, + } + yield { + type: "response.function_call_arguments.delta", + delta: '{"result":"hi"}', + // Note: intentionally omit call_id + name to simulate tool-call-only streams. + item_id: "fc_1", + output_index: 0, + } + yield { + type: "response.completed", + response: { + id: "resp_1", + status: "completed", + output: [ + { + type: "function_call", + call_id: "call_1", + name: "attempt_completion", + arguments: '{"result":"hi"}', + }, + ], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + } + }, + }), + }, + } + + const stream = handler.createMessage("system", [{ role: "user", content: "hello" } as any], { + taskId: "t", + toolProtocol: "native", + tools: [], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + if (chunk.type === "tool_call_partial") { + // Simulate Task.ts behavior so finish_reason handling can emit tool_call_end elsewhere + NativeToolCallParser.processRawChunk({ + index: chunk.index, + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + }) + } + } + + const toolChunks = chunks.filter((c) => c.type === "tool_call_partial") + expect(toolChunks.length).toBeGreaterThan(0) + expect(toolChunks[0]).toMatchObject({ + type: "tool_call_partial", + id: "call_1", + name: "attempt_completion", + }) + }) +}) diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts new file mode 100644 index 00000000000..f35d6e61ee7 --- /dev/null +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -0,0 +1,26 @@ +// npx vitest run api/providers/__tests__/openai-codex.spec.ts + +import { OpenAiCodexHandler } from "../openai-codex" + +describe("OpenAiCodexHandler.getModel", () => { + it.each(["gpt-5.1", "gpt-5", "gpt-5.1-codex", "gpt-5-codex", "gpt-5-codex-mini"])( + "should return specified model when a valid model id is provided: %s", + (apiModelId) => { + const handler = new OpenAiCodexHandler({ apiModelId }) + const model = handler.getModel() + + expect(model.id).toBe(apiModelId) + expect(model.info).toBeDefined() + // Default reasoning effort for GPT-5 family + expect(model.info.reasoningEffort).toBe("medium") + }, + ) + + it("should fall back to default model when an invalid model id is provided", () => { + const handler = new OpenAiCodexHandler({ apiModelId: "not-a-real-model" }) + const model = handler.getModel() + + expect(model.id).toBe("gpt-5.2-codex") + expect(model.info).toBeDefined() + }) +}) diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index aaf6f03171b..4143ff709aa 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -18,6 +18,7 @@ export { LiteLLMHandler } from "./lite-llm" export { LmStudioHandler } from "./lm-studio" export { MistralHandler } from "./mistral" export { NanoGptHandler } from "./nano-gpt" // kilocode_change +export { OpenAiCodexHandler } from "./openai-codex" export { OpenAiNativeHandler } from "./openai-native" export { OpenAiHandler } from "./openai" export { OpenRouterHandler } from "./openrouter" diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts new file mode 100644 index 00000000000..fd2d510732a --- /dev/null +++ b/src/api/providers/openai-codex.ts @@ -0,0 +1,1122 @@ +import * as os from "os" +import { v7 as uuidv7 } from "uuid" +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { + type ModelInfo, + openAiCodexDefaultModelId, + OpenAiCodexModelId, + openAiCodexModels, + type ReasoningEffort, + type ReasoningEffortExtended, + ApiProviderError, +} from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import type { ApiHandlerOptions } from "../../shared/api" + +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" + +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { isMcpTool } from "../../utils/mcp-name" +import { openAiCodexOAuthManager } from "../../integrations/openai-codex/oauth" +import { t } from "../../i18n" + +import { DEFAULT_HEADERS } from "./constants" // kilocode-change + +// Get extension version for User-Agent header +const extensionVersion: string = require("../../package.json").version ?? "unknown" + +export type OpenAiCodexModel = ReturnType + +/** + * OpenAI Codex base URL for API requests + * Per the implementation guide: requests are routed to chatgpt.com/backend-api/codex + */ +const CODEX_API_BASE_URL = "https://chatgpt.com/backend-api/codex" + +/** + * OpenAiCodexHandler - Uses OpenAI Responses API with OAuth authentication + * + * Key differences from OpenAiNativeHandler: + * - Uses OAuth Bearer tokens instead of API keys + * - Routes requests to Codex backend (chatgpt.com/backend-api/codex) + * - Subscription-based pricing (no per-token costs) + * - Limited model subset + * - Custom headers for Codex backend + */ +export class OpenAiCodexHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + private readonly providerName = "OpenAI Codex" + private client?: OpenAI + // Complete response output array + private lastResponseOutput: any[] | undefined + // Last top-level response id + private lastResponseId: string | undefined + // Abort controller for cancelling ongoing requests + private abortController?: AbortController + // Session ID for the Codex API (persists for the lifetime of the handler) + private readonly sessionId: string + /** + * Some Codex/Responses streams emit tool-call argument deltas without stable call id/name. + * Track the last observed tool identity from output_item events so we can still + * emit `tool_call_partial` chunks (tool-call-only streams). + */ + private pendingToolCallId: string | undefined + private pendingToolCallName: string | undefined + + // Event types handled by the shared event processor + private readonly coreHandledEventTypes = new Set([ + "response.text.delta", + "response.output_text.delta", + "response.reasoning.delta", + "response.reasoning_text.delta", + "response.reasoning_summary.delta", + "response.reasoning_summary_text.delta", + "response.refusal.delta", + "response.output_item.added", + "response.output_item.done", + "response.done", + "response.completed", + "response.tool_call_arguments.delta", + "response.function_call_arguments.delta", + "response.tool_call_arguments.done", + "response.function_call_arguments.done", + ]) + + constructor(options: ApiHandlerOptions) { + super() + this.options = options + // Generate a new session ID for standalone handler usage (fallback) + this.sessionId = uuidv7() + } + + private normalizeUsage(usage: any, model: OpenAiCodexModel): ApiStreamUsageChunk | undefined { + if (!usage) return undefined + + const inputDetails = usage.input_tokens_details ?? usage.prompt_tokens_details + + const hasCachedTokens = typeof inputDetails?.cached_tokens === "number" + const hasCacheMissTokens = typeof inputDetails?.cache_miss_tokens === "number" + const cachedFromDetails = hasCachedTokens ? inputDetails.cached_tokens : 0 + const missFromDetails = hasCacheMissTokens ? inputDetails.cache_miss_tokens : 0 + + let totalInputTokens = usage.input_tokens ?? usage.prompt_tokens ?? 0 + if (totalInputTokens === 0 && inputDetails && (cachedFromDetails > 0 || missFromDetails > 0)) { + totalInputTokens = cachedFromDetails + missFromDetails + } + + const totalOutputTokens = usage.output_tokens ?? usage.completion_tokens ?? 0 + const cacheWriteTokens = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? 0 + const cacheReadTokens = + usage.cache_read_input_tokens ?? usage.cache_read_tokens ?? usage.cached_tokens ?? cachedFromDetails ?? 0 + + const reasoningTokens = + typeof usage.output_tokens_details?.reasoning_tokens === "number" + ? usage.output_tokens_details.reasoning_tokens + : undefined + + // Subscription-based: no per-token costs + const out: ApiStreamUsageChunk = { + type: "usage", + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheWriteTokens, + cacheReadTokens, + ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), + totalCost: 0, // Subscription-based pricing + } + return out + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const model = this.getModel() + yield* this.handleResponsesApiMessage(model, systemPrompt, messages, metadata) + } + + private async *handleResponsesApiMessage( + model: OpenAiCodexModel, + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + // Reset state for this request + this.lastResponseOutput = undefined + this.lastResponseId = undefined + this.pendingToolCallId = undefined + this.pendingToolCallName = undefined + + // Get access token from OAuth manager + let accessToken = await openAiCodexOAuthManager.getAccessToken() + if (!accessToken) { + throw new Error( + t("common:errors.openAiCodex.notAuthenticated", { + defaultValue: + "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + }), + ) + } + + // Resolve reasoning effort + const reasoningEffort = this.getReasoningEffort(model) + + // Format conversation + const formattedInput = this.formatFullConversation(systemPrompt, messages) + + // Build request body + // Per the implementation guide: Codex backend may reject some parameters + // Notably: max_output_tokens and prompt_cache_retention may be rejected + const requestBody = this.buildRequestBody(model, formattedInput, systemPrompt, reasoningEffort, metadata) + + // Make the request with retry on auth failure + for (let attempt = 0; attempt < 2; attempt++) { + try { + yield* this.executeRequest(requestBody, model, accessToken, metadata?.taskId) + return + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const isAuthFailure = /unauthorized|invalid token|not authenticated|authentication|401/i.test(message) + + if (attempt === 0 && isAuthFailure) { + // Force refresh the token for retry + const refreshed = await openAiCodexOAuthManager.forceRefreshAccessToken() + if (!refreshed) { + throw new Error( + t("common:errors.openAiCodex.notAuthenticated", { + defaultValue: + "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + }), + ) + } + accessToken = refreshed + continue + } + throw error + } + } + } + + private buildRequestBody( + model: OpenAiCodexModel, + formattedInput: any, + systemPrompt: string, + reasoningEffort: ReasoningEffortExtended | undefined, + metadata?: ApiHandlerCreateMessageMetadata, + ): any { + const ensureAllRequired = (schema: any): any => { + if (!schema || typeof schema !== "object" || schema.type !== "object") { + return schema + } + + const result = { ...schema } + if (result.additionalProperties !== false) { + result.additionalProperties = false + } + + if (result.properties) { + const allKeys = Object.keys(result.properties) + result.required = allKeys + + const newProps = { ...result.properties } + for (const key of allKeys) { + const prop = newProps[key] + if (prop.type === "object") { + newProps[key] = ensureAllRequired(prop) + } else if (prop.type === "array" && prop.items?.type === "object") { + newProps[key] = { + ...prop, + items: ensureAllRequired(prop.items), + } + } + } + result.properties = newProps + } + + return result + } + + const ensureAdditionalPropertiesFalse = (schema: any): any => { + if (!schema || typeof schema !== "object" || schema.type !== "object") { + return schema + } + + const result = { ...schema } + if (result.additionalProperties !== false) { + result.additionalProperties = false + } + + if (result.properties) { + const newProps = { ...result.properties } + for (const key of Object.keys(result.properties)) { + const prop = newProps[key] + if (prop && prop.type === "object") { + newProps[key] = ensureAdditionalPropertiesFalse(prop) + } else if (prop && prop.type === "array" && prop.items?.type === "object") { + newProps[key] = { + ...prop, + items: ensureAdditionalPropertiesFalse(prop.items), + } + } + } + result.properties = newProps + } + + return result + } + + interface ResponsesRequestBody { + model: string + input: Array<{ role: "user" | "assistant"; content: any[] } | { type: string; content: string }> + stream: boolean + reasoning?: { effort?: ReasoningEffortExtended; summary?: "auto" } + temperature?: number + store?: boolean + instructions?: string + include?: string[] + tools?: Array<{ + type: "function" + name: string + description?: string + parameters?: any + strict?: boolean + }> + tool_choice?: any + parallel_tool_calls?: boolean + } + + // Per the implementation guide: Codex backend may reject max_output_tokens + // and prompt_cache_retention, so we omit them + const body: ResponsesRequestBody = { + model: model.id, + input: formattedInput, + stream: true, + store: false, + instructions: systemPrompt, + // Only include encrypted reasoning content when reasoning effort is set + ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), + ...(reasoningEffort + ? { + reasoning: { + ...(reasoningEffort ? { effort: reasoningEffort } : {}), + summary: "auto" as const, + }, + } + : {}), + ...(metadata?.tools && { + tools: metadata.tools + .filter((tool) => tool.type === "function") + .map((tool) => { + const isMcp = isMcpTool(tool.function.name) + return { + type: "function", + name: tool.function.name, + description: tool.function.description, + parameters: isMcp + ? ensureAdditionalPropertiesFalse(tool.function.parameters) + : ensureAllRequired(tool.function.parameters), + strict: !isMcp, + } + }), + }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), + } + + // For native tool protocol, control parallel tool calls + if (metadata?.toolProtocol === "native") { + body.parallel_tool_calls = metadata.parallelToolCalls ?? false + } + + return body + } + + private async *executeRequest( + requestBody: any, + model: OpenAiCodexModel, + accessToken: string, + taskId?: string, + ): ApiStream { + // Create AbortController for cancellation + this.abortController = new AbortController() + + try { + // Prefer OpenAI SDK streaming (same approach as openai-native) so event handling + // is consistent across providers. + try { + // Get ChatGPT account ID for organization subscriptions + const accountId = await openAiCodexOAuthManager.getAccountId() + + // Build Codex-specific headers. Authorization is provided by the SDK apiKey. + const codexHeaders: Record = { + originator: "kilo-code", // kilocode_change + session_id: taskId || this.sessionId, + "User-Agent": DEFAULT_HEADERS["User-Agent"], // kilocode_change + ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), + } + + // Allow tests to inject a client. If none is injected, create one for this request. + const client = + this.client ?? + new OpenAI({ + apiKey: accessToken, + baseURL: CODEX_API_BASE_URL, + defaultHeaders: codexHeaders, + }) + + const stream = (await (client as any).responses.create(requestBody, { + signal: this.abortController.signal, + // If the SDK supports per-request overrides, ensure headers are present. + headers: codexHeaders, + })) as AsyncIterable + + if (typeof (stream as any)?.[Symbol.asyncIterator] !== "function") { + throw new Error( + "OpenAI SDK did not return an AsyncIterable for Responses API streaming. Falling back to SSE.", + ) + } + + for await (const event of stream) { + if (this.abortController.signal.aborted) { + break + } + + for await (const outChunk of this.processEvent(event, model)) { + yield outChunk + } + } + } catch (_sdkErr) { + // Fallback to manual SSE via fetch (Codex backend). + yield* this.makeCodexRequest(requestBody, model, accessToken, taskId) + } + } finally { + this.abortController = undefined + } + } + + private formatFullConversation(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): any { + const formattedInput: any[] = [] + + for (const message of messages) { + // Check if this is a reasoning item + if ((message as any).type === "reasoning") { + formattedInput.push(message) + continue + } + + if (message.role === "user") { + const content: any[] = [] + const toolResults: any[] = [] + + if (typeof message.content === "string") { + content.push({ type: "input_text", text: message.content }) + } else if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === "text") { + content.push({ type: "input_text", text: block.text }) + } else if (block.type === "image") { + const image = block as Anthropic.Messages.ImageBlockParam + const imageUrl = + "media_type" in image.source && "data" in image.source + ? `data:${image.source.media_type};base64,${image.source.data}` + : image.source.url // kilocode_change + content.push({ type: "input_image", image_url: imageUrl }) + } else if (block.type === "tool_result") { + const result = + typeof block.content === "string" + ? block.content + : block.content?.map((c) => (c.type === "text" ? c.text : "")).join("") || "" + toolResults.push({ + type: "function_call_output", + call_id: block.tool_use_id, + output: result, + }) + } + } + } + + if (content.length > 0) { + formattedInput.push({ role: "user", content }) + } + + if (toolResults.length > 0) { + formattedInput.push(...toolResults) + } + } else if (message.role === "assistant") { + const content: any[] = [] + const toolCalls: any[] = [] + + if (typeof message.content === "string") { + content.push({ type: "output_text", text: message.content }) + } else if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === "text") { + content.push({ type: "output_text", text: block.text }) + } else if (block.type === "tool_use") { + toolCalls.push({ + type: "function_call", + call_id: block.id, + name: block.name, + arguments: JSON.stringify(block.input), + }) + } + } + } + + if (content.length > 0) { + formattedInput.push({ role: "assistant", content }) + } + + if (toolCalls.length > 0) { + formattedInput.push(...toolCalls) + } + } + } + + return formattedInput + } + + private async *makeCodexRequest( + requestBody: any, + model: OpenAiCodexModel, + accessToken: string, + taskId?: string, + ): ApiStream { + // Per the implementation guide: route to Codex backend with Bearer token + const url = `${CODEX_API_BASE_URL}/responses` + + // Get ChatGPT account ID for organization subscriptions + const accountId = await openAiCodexOAuthManager.getAccountId() + + // Build headers with required Codex-specific fields + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + originator: "kilo-code", // kilocode_change + session_id: taskId || this.sessionId, + "User-Agent": DEFAULT_HEADERS["User-Agent"], // kilocode_change + } + + // Add ChatGPT-Account-Id if available (required for organization subscriptions) + if (accountId) { + headers["ChatGPT-Account-Id"] = accountId + } + + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal: this.abortController?.signal, + }) + + if (!response.ok) { + const errorText = await response.text() + + let errorMessage = t("common:errors.api.apiRequestFailed", { status: response.status }) + let errorDetails = "" + + try { + const errorJson = JSON.parse(errorText) + if (errorJson.error?.message) { + errorDetails = errorJson.error.message + } else if (errorJson.message) { + errorDetails = errorJson.message + } else if (errorJson.detail) { + errorDetails = errorJson.detail + } else { + errorDetails = errorText + } + } catch { + errorDetails = errorText + } + + switch (response.status) { + case 400: + errorMessage = t("common:errors.openAiCodex.invalidRequest") + break + case 401: + errorMessage = t("common:errors.openAiCodex.authenticationFailed") + break + case 403: + errorMessage = t("common:errors.openAiCodex.accessDenied") + break + case 404: + errorMessage = t("common:errors.openAiCodex.endpointNotFound") + break + case 429: + errorMessage = t("common:errors.openAiCodex.rateLimitExceeded") + break + case 500: + case 502: + case 503: + errorMessage = t("common:errors.openAiCodex.serviceError") + break + default: + errorMessage = t("common:errors.openAiCodex.genericError", { status: response.status }) + } + + if (errorDetails) { + errorMessage += ` - ${errorDetails}` + } + + throw new Error(errorMessage) + } + + if (!response.body) { + throw new Error(t("common:errors.openAiCodex.noResponseBody")) + } + + yield* this.handleStreamResponse(response.body, model) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") + TelemetryService.instance.captureException(apiError) + + if (error instanceof Error) { + if (error.message.includes("Codex API")) { + throw error + } + throw new Error(t("common:errors.openAiCodex.connectionFailed", { message: error.message })) + } + throw new Error(t("common:errors.openAiCodex.unexpectedConnectionError")) + } + } + + private async *handleStreamResponse(body: ReadableStream, model: OpenAiCodexModel): ApiStream { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = "" + let hasContent = false + + try { + while (true) { + if (this.abortController?.signal.aborted) { + break + } + + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() || "" + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim() + if (data === "[DONE]") { + continue + } + + try { + const parsed = JSON.parse(data) + + // Capture response metadata + if (parsed.response?.output && Array.isArray(parsed.response.output)) { + this.lastResponseOutput = parsed.response.output + } + if (parsed.response?.id) { + this.lastResponseId = parsed.response.id as string + } + + // Delegate standard event types + if (parsed?.type && this.coreHandledEventTypes.has(parsed.type)) { + // Capture tool call identity from output_item events so we can + // emit tool_call_partial for subsequent function_call_arguments.delta events + if ( + parsed.type === "response.output_item.added" || + parsed.type === "response.output_item.done" + ) { + const item = parsed.item + if (item && (item.type === "function_call" || item.type === "tool_call")) { + const callId = item.call_id || item.tool_call_id || item.id + const name = item.name || item.function?.name || item.function_name + if (typeof callId === "string" && callId.length > 0) { + this.pendingToolCallId = callId + this.pendingToolCallName = typeof name === "string" ? name : undefined + } + } + } + + // Some Codex streams only return tool calls (no text). Treat tool output as content. + if ( + parsed.type === "response.function_call_arguments.delta" || + parsed.type === "response.tool_call_arguments.delta" || + parsed.type === "response.output_item.added" || + parsed.type === "response.output_item.done" + ) { + hasContent = true + } + + for await (const outChunk of this.processEvent(parsed, model)) { + if (outChunk.type === "text" || outChunk.type === "reasoning") { + hasContent = true + } + yield outChunk + } + continue + } + + // Handle complete response + if (parsed.response && parsed.response.output && Array.isArray(parsed.response.output)) { + for (const outputItem of parsed.response.output) { + if (outputItem.type === "text" && outputItem.content) { + for (const content of outputItem.content) { + if (content.type === "text" && content.text) { + hasContent = true + yield { type: "text", text: content.text } + } + } + } + if (outputItem.type === "reasoning" && Array.isArray(outputItem.summary)) { + for (const summary of outputItem.summary) { + if (summary?.type === "summary_text" && typeof summary.text === "string") { + hasContent = true + yield { type: "reasoning", text: summary.text } + } + } + } + } + if (parsed.response.usage) { + const usageData = this.normalizeUsage(parsed.response.usage, model) + if (usageData) { + yield usageData + } + } + } else if ( + parsed.type === "response.text.delta" || + parsed.type === "response.output_text.delta" + ) { + if (parsed.delta) { + hasContent = true + yield { type: "text", text: parsed.delta } + } + } else if ( + parsed.type === "response.reasoning.delta" || + parsed.type === "response.reasoning_text.delta" + ) { + if (parsed.delta) { + hasContent = true + yield { type: "reasoning", text: parsed.delta } + } + } else if ( + parsed.type === "response.reasoning_summary.delta" || + parsed.type === "response.reasoning_summary_text.delta" + ) { + if (parsed.delta) { + hasContent = true + yield { type: "reasoning", text: parsed.delta } + } + } else if (parsed.type === "response.refusal.delta") { + if (parsed.delta) { + hasContent = true + yield { type: "text", text: `[Refusal] ${parsed.delta}` } + } + } else if (parsed.type === "response.output_item.added") { + if (parsed.item) { + if (parsed.item.type === "text" && parsed.item.text) { + hasContent = true + yield { type: "text", text: parsed.item.text } + } else if (parsed.item.type === "reasoning" && parsed.item.text) { + hasContent = true + yield { type: "reasoning", text: parsed.item.text } + } else if (parsed.item.type === "message" && parsed.item.content) { + for (const content of parsed.item.content) { + if (content.type === "text" && content.text) { + hasContent = true + yield { type: "text", text: content.text } + } + } + } + } + } else if (parsed.type === "response.error" || parsed.type === "error") { + if (parsed.error || parsed.message) { + throw new Error( + t("common:errors.openAiCodex.apiError", { + message: parsed.error?.message || parsed.message || "Unknown error", + }), + ) + } + } else if (parsed.type === "response.failed") { + if (parsed.error || parsed.message) { + throw new Error( + t("common:errors.openAiCodex.responseFailed", { + message: parsed.error?.message || parsed.message || "Unknown failure", + }), + ) + } + } else if (parsed.type === "response.completed" || parsed.type === "response.done") { + if (parsed.response?.output && Array.isArray(parsed.response.output)) { + this.lastResponseOutput = parsed.response.output + } + if (parsed.response?.id) { + this.lastResponseId = parsed.response.id as string + } + + if ( + !hasContent && + parsed.response && + parsed.response.output && + Array.isArray(parsed.response.output) + ) { + for (const outputItem of parsed.response.output) { + if (outputItem.type === "message" && outputItem.content) { + for (const content of outputItem.content) { + if (content.type === "output_text" && content.text) { + hasContent = true + yield { type: "text", text: content.text } + } + } + } + if (outputItem.type === "reasoning" && Array.isArray(outputItem.summary)) { + for (const summary of outputItem.summary) { + if ( + summary?.type === "summary_text" && + typeof summary.text === "string" + ) { + hasContent = true + yield { type: "reasoning", text: summary.text } + } + } + } + } + } + } else if (parsed.choices?.[0]?.delta?.content) { + hasContent = true + yield { type: "text", text: parsed.choices[0].delta.content } + } else if ( + parsed.item && + typeof parsed.item.text === "string" && + parsed.item.text.length > 0 + ) { + hasContent = true + yield { type: "text", text: parsed.item.text } + } else if (parsed.usage) { + const usageData = this.normalizeUsage(parsed.usage, model) + if (usageData) { + yield usageData + } + } + } catch (e) { + if (!(e instanceof SyntaxError)) { + throw e + } + } + } else if (line.trim() && !line.startsWith(":")) { + try { + const parsed = JSON.parse(line) + if (parsed.content || parsed.text || parsed.message) { + hasContent = true + yield { type: "text", text: parsed.content || parsed.text || parsed.message } + } + } catch { + // Not JSON, ignore + } + } + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") + TelemetryService.instance.captureException(apiError) + + if (error instanceof Error) { + throw new Error(t("common:errors.openAiCodex.streamProcessingError", { message: error.message })) + } + throw new Error(t("common:errors.openAiCodex.unexpectedStreamError")) + } finally { + reader.releaseLock() + } + } + + private async *processEvent(event: any, model: OpenAiCodexModel): ApiStream { + if (event?.response?.output && Array.isArray(event.response.output)) { + this.lastResponseOutput = event.response.output + } + if (event?.response?.id) { + this.lastResponseId = event.response.id as string + } + + // Handle text deltas + if (event?.type === "response.text.delta" || event?.type === "response.output_text.delta") { + if (event?.delta) { + yield { type: "text", text: event.delta } + } + return + } + + // Handle reasoning deltas + if ( + event?.type === "response.reasoning.delta" || + event?.type === "response.reasoning_text.delta" || + event?.type === "response.reasoning_summary.delta" || + event?.type === "response.reasoning_summary_text.delta" + ) { + if (event?.delta) { + yield { type: "reasoning", text: event.delta } + } + return + } + + // Handle refusal deltas + if (event?.type === "response.refusal.delta") { + if (event?.delta) { + yield { type: "text", text: `[Refusal] ${event.delta}` } + } + return + } + + // Handle tool/function call deltas + if ( + event?.type === "response.tool_call_arguments.delta" || + event?.type === "response.function_call_arguments.delta" + ) { + const callId = event.call_id || event.tool_call_id || event.id || this.pendingToolCallId + const name = event.name || event.function_name || this.pendingToolCallName + const args = event.delta || event.arguments + + // Codex/Responses may stream tool-call arguments, but these delta events are not guaranteed + // to include a stable id/name. Avoid emitting incomplete tool_call_partial chunks because + // NativeToolCallParser requires a name to start a call. + if (typeof callId === "string" && callId.length > 0 && typeof name === "string" && name.length > 0) { + yield { + type: "tool_call_partial", + index: event.index ?? 0, + id: callId, + name, + arguments: typeof args === "string" ? args : "", + } + } + return + } + + // Handle tool/function call completion + if ( + event?.type === "response.tool_call_arguments.done" || + event?.type === "response.function_call_arguments.done" + ) { + return + } + + // Handle output item events + if (event?.type === "response.output_item.added" || event?.type === "response.output_item.done") { + const item = event?.item + if (item) { + // Capture tool identity so subsequent argument deltas can be attributed. + if (item.type === "function_call" || item.type === "tool_call") { + const callId = item.call_id || item.tool_call_id || item.id + const name = item.name || item.function?.name || item.function_name + if (typeof callId === "string" && callId.length > 0) { + this.pendingToolCallId = callId + this.pendingToolCallName = typeof name === "string" ? name : undefined + } + } + + if (item.type === "text" && item.text) { + yield { type: "text", text: item.text } + } else if (item.type === "reasoning" && item.text) { + yield { type: "reasoning", text: item.text } + } else if (item.type === "message" && Array.isArray(item.content)) { + for (const content of item.content) { + if ((content?.type === "text" || content?.type === "output_text") && content?.text) { + yield { type: "text", text: content.text } + } + } + } else if ( + (item.type === "function_call" || item.type === "tool_call") && + event.type === "response.output_item.done" + ) { + const callId = item.call_id || item.tool_call_id || item.id + if (callId) { + const args = item.arguments || item.function?.arguments || item.function_arguments + yield { + type: "tool_call", + id: callId, + name: item.name || item.function?.name || item.function_name || "", + arguments: typeof args === "string" ? args : "{}", + } + } + } + } + return + } + + // Handle completion events + if (event?.type === "response.done" || event?.type === "response.completed") { + const usage = event?.response?.usage || event?.usage || undefined + const usageData = this.normalizeUsage(usage, model) + if (usageData) { + yield usageData + } + return + } + + // Fallbacks + if (event?.choices?.[0]?.delta?.content) { + yield { type: "text", text: event.choices[0].delta.content } + return + } + + if (event?.usage) { + const usageData = this.normalizeUsage(event.usage, model) + if (usageData) { + yield usageData + } + } + } + + private getReasoningEffort(model: OpenAiCodexModel): ReasoningEffortExtended | undefined { + const selected = (this.options.reasoningEffort as any) ?? (model.info.reasoningEffort as any) + return selected && selected !== "disable" && selected !== "none" ? (selected as any) : undefined + } + + override getModel() { + const modelId = this.options.apiModelId + + let id = modelId && modelId in openAiCodexModels ? (modelId as OpenAiCodexModelId) : openAiCodexDefaultModelId + + const info: ModelInfo = openAiCodexModels[id] + + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) + + return { id, info, ...params } + } + + getEncryptedContent(): { encrypted_content: string; id?: string } | undefined { + if (!this.lastResponseOutput) return undefined + + const reasoningItem = this.lastResponseOutput.find( + (item) => item.type === "reasoning" && item.encrypted_content, + ) + + if (!reasoningItem?.encrypted_content) return undefined + + return { + encrypted_content: reasoningItem.encrypted_content, + ...(reasoningItem.id ? { id: reasoningItem.id } : {}), + } + } + + getResponseId(): string | undefined { + return this.lastResponseId + } + + async completePrompt(prompt: string): Promise { + this.abortController = new AbortController() + + try { + const model = this.getModel() + + // Get access token + const accessToken = await openAiCodexOAuthManager.getAccessToken() + if (!accessToken) { + throw new Error( + t("common:errors.openAiCodex.notAuthenticated", { + defaultValue: + "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + }), + ) + } + + const reasoningEffort = this.getReasoningEffort(model) + + const requestBody: any = { + model: model.id, + input: [ + { + role: "user", + content: [{ type: "input_text", text: prompt }], + }, + ], + stream: false, + store: false, + ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), + } + + if (reasoningEffort) { + requestBody.reasoning = { + effort: reasoningEffort, + summary: "auto" as const, + } + } + + const url = `${CODEX_API_BASE_URL}/responses` + + // Get ChatGPT account ID for organization subscriptions + const accountId = await openAiCodexOAuthManager.getAccountId() + + // Build headers with required Codex-specific fields + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + originator: "kilo-code", // kilocode_change + session_id: this.sessionId, + "User-Agent": DEFAULT_HEADERS["User-Agent"], // kilocode_change + } + + // Add ChatGPT-Account-Id if available + if (accountId) { + headers["ChatGPT-Account-Id"] = accountId + } + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal: this.abortController.signal, + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error( + t("common:errors.openAiCodex.genericError", { status: response.status }) + + (errorText ? `: ${errorText}` : ""), + ) + } + + const responseData = await response.json() + + if (responseData?.output && Array.isArray(responseData.output)) { + for (const outputItem of responseData.output) { + if (outputItem.type === "message" && outputItem.content) { + for (const content of outputItem.content) { + if (content.type === "output_text" && content.text) { + return content.text + } + } + } + } + } + + if (responseData?.text) { + return responseData.text + } + + return "" + } catch (error) { + const errorModel = this.getModel() + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, errorModel.id, "completePrompt") + TelemetryService.instance.captureException(apiError) + + if (error instanceof Error) { + throw new Error(t("common:errors.openAiCodex.completionError", { message: error.message })) + } + throw error + } finally { + this.abortController = undefined + } + } +} diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index ac9d9bea44a..6b9f3c0e901 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -16,7 +16,7 @@ import { RouterProvider } from "./router-provider" import { getModelParams } from "../transform/model-params" import { getModels } from "./fetchers/modelCache" -const ORIGIN_APP = "roo-code" +const ORIGIN_APP = "kilo-code" const DEFAULT_HEADERS = { "X-Unbound-Metadata": JSON.stringify({ labels: [{ key: "app", value: "kilo-code" }] }), diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 92af0fca97b..8db13bec82b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3,6 +3,7 @@ import * as path from "path" import * as vscode from "vscode" import os from "os" import crypto from "crypto" +import { v7 as uuidv7 } from "uuid" import EventEmitter from "events" import { AskIgnoredError } from "./AskIgnoredError" @@ -460,7 +461,7 @@ export class Task extends EventEmitter implements TaskLike { ) } - this.taskId = historyItem ? historyItem.id : crypto.randomUUID() + this.taskId = historyItem ? historyItem.id : uuidv7() this.taskIsFavorited = historyItem?.isFavorited // kilocode_change this.rootTaskId = historyItem ? historyItem.rootTaskId : rootTask?.taskId this.parentTaskId = historyItem ? historyItem.parentTaskId : parentTask?.taskId @@ -488,7 +489,7 @@ export class Task extends EventEmitter implements TaskLike { }) this.apiConfiguration = apiConfiguration - this.api = buildApiHandler(apiConfiguration) + this.api = buildApiHandler(this.apiConfiguration) // kilocode_change start: Listen for model changes in virtual quota fallback if (this.api instanceof VirtualQuotaFallbackHandler) { this.api.on("handlerChanged", () => { @@ -1545,7 +1546,7 @@ export class Task extends EventEmitter implements TaskLike { public updateApiConfiguration(newApiConfiguration: ProviderSettings): void { // Update the configuration and rebuild the API handler this.apiConfiguration = newApiConfiguration - this.api = buildApiHandler(newApiConfiguration) + this.api = buildApiHandler(this.apiConfiguration) // IMPORTANT: Do NOT change the parser based on the new configuration! // The task's tool protocol is locked at creation time and must remain diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index bc71afd5dce..35bc60a6f4f 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -26,6 +26,14 @@ vi.mock("delay", () => ({ import delay from "delay" +vi.mock("uuid", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + v7: vi.fn(() => "00000000-0000-7000-8000-000000000000"), + } +}) + vi.mock("execa", () => ({ execa: vi.fn(), })) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f896a382c5c..fd5ee93f425 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2432,6 +2432,14 @@ export class ClineProvider return false } })(), + openAiCodexIsAuthenticated: await (async () => { + try { + const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") + return await openAiCodexOAuthManager.isAuthenticated() + } catch { + return false + } + })(), debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f988f2aab5e..ef462358adf 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3078,6 +3078,45 @@ export const webviewMessageHandler = async ( } break } + case "openAiCodexSignIn": { + try { + const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") + const authUrl = openAiCodexOAuthManager.startAuthorizationFlow() + + // Open the authorization URL in the browser + await vscode.env.openExternal(vscode.Uri.parse(authUrl)) + + // Wait for the callback in a separate promise (non-blocking) + openAiCodexOAuthManager + .waitForCallback() + .then(async () => { + vscode.window.showInformationMessage("Successfully signed in to OpenAI Codex") + await provider.postStateToWebview() + }) + .catch((error) => { + provider.log(`OpenAI Codex OAuth callback failed: ${error}`) + if (!String(error).includes("timed out")) { + vscode.window.showErrorMessage(`OpenAI Codex sign in failed: ${error.message || error}`) + } + }) + } catch (error) { + provider.log(`OpenAI Codex OAuth failed: ${error}`) + vscode.window.showErrorMessage("OpenAI Codex sign in failed.") + } + break + } + case "openAiCodexSignOut": { + try { + const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") + await openAiCodexOAuthManager.clearCredentials() + vscode.window.showInformationMessage("Signed out from OpenAI Codex") + await provider.postStateToWebview() + } catch (error) { + provider.log(`OpenAI Codex sign out failed: ${error}`) + vscode.window.showErrorMessage("OpenAI Codex sign out failed.") + } + break + } case "rooCloudManualUrl": { try { if (!message.text) { diff --git a/src/extension.ts b/src/extension.ts index 3d32ae7e54d..c882c4c3eff 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -27,6 +27,7 @@ import { ClineProvider } from "./core/webview/ClineProvider" import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" import { claudeCodeOAuthManager } from "./integrations/claude-code/oauth" +import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth" import { McpServerManager } from "./services/mcp/McpServerManager" import { CodeIndexManager } from "./services/code-index/manager" import { registerCommitMessageProvider } from "./services/commit-message" @@ -157,6 +158,9 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize Claude Code OAuth manager for direct API access. claudeCodeOAuthManager.initialize(context, (message) => outputChannel.appendLine(message)) + // Initialize OpenAI Codex OAuth manager for ChatGPT subscription-based access. + openAiCodexOAuthManager.initialize(context, (message) => outputChannel.appendLine(message)) + // Get default commands from configuration. const defaultCommands = vscode.workspace.getConfiguration(Package.name).get("allowedCommands") || [] diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 58fb201d2d8..8888d5a0448 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -141,6 +141,24 @@ "roo": { "authenticationRequired": "يتطلب موفر Roo المصادقة السحابية. يرجى تسجيل الدخول إلى Roo Code Cloud." }, + "openAiCodex": { + "notAuthenticated": "ما سجلت دخول في OpenAI Codex. سجل دخولك باستخدام OAuth الخاص بـ OpenAI Codex.", + "invalidRequest": "طلب غير صحيح لـ Codex API. تأكد من معاملات الإدخال.", + "authenticationFailed": "فشل تسجيل الدخول. سجل دخولك مرة ثانية في OpenAI Codex.", + "accessDenied": "الوصول ممنوع. اشتراكك في ChatGPT قد لا يشمل الوصول لـ Codex.", + "endpointNotFound": "نقطة نهاية Codex API غير موجودة.", + "rateLimitExceeded": "تجاوزت حد الطلبات. جرّب مرة ثانية لاحقاً.", + "serviceError": "خطأ في خدمة OpenAI Codex. جرّب مرة ثانية لاحقاً.", + "genericError": "خطأ في Codex API ({{status}})", + "noResponseBody": "خطأ Codex API: لا يوجد محتوى في الاستجابة", + "connectionFailed": "فشل الاتصال بـ Codex API: {{message}}", + "unexpectedConnectionError": "خطأ غير متوقع في الاتصال بـ Codex API", + "apiError": "خطأ Codex API: {{message}}", + "responseFailed": "فشلت الاستجابة: {{message}}", + "streamProcessingError": "خطأ في معالجة تدفق الاستجابة: {{message}}", + "unexpectedStreamError": "خطأ غير متوقع في معالجة تدفق الاستجابة", + "completionError": "خطأ في إكمال OpenAI Codex: {{message}}" + }, "message": { "no_active_task_to_delete": "لم يتم العثور على مهمة نشطة. لا يمكن حذف الرسائل.", "invalid_timestamp_for_deletion": "طابع زمني غير صحيح مقدم للحذف.", @@ -156,7 +174,8 @@ "manual_url_auth_failed": "فشل في المصادقة", "manual_url_auth_error": "حدث خطأ في المصادقة", "api": { - "invalidKeyInvalidChars": "مفتاح API يحتوي على رموز غير صالحة." + "invalidKeyInvalidChars": "مفتاح API يحتوي على رموز غير صالحة.", + "apiRequestFailed": "فشل طلب API ({{status}})" } }, "warnings": { diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index ffe8793c61f..b9d9ead5d20 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -147,8 +147,27 @@ "roo": { "authenticationRequired": "El proveïdor Roo requereix autenticació al núvol. Si us plau, inicieu sessió a Roo Code Cloud." }, + "openAiCodex": { + "notAuthenticated": "No esteu autenticat amb OpenAI Codex. Si us plau, inicieu sessió mitjançant el flux OAuth d'OpenAI Codex.", + "invalidRequest": "Sol·licitud no vàlida a l'API de Codex. Si us plau, comproveu els paràmetres d'entrada.", + "authenticationFailed": "Ha fallat l'autenticació. Si us plau, torneu a autenticar-vos amb OpenAI Codex.", + "accessDenied": "Accés denegat. La vostra subscripció a ChatGPT pot no incloure accés a Codex.", + "endpointNotFound": "Punt final de l'API de Codex no trobat.", + "rateLimitExceeded": "S'ha superat el límit de velocitat. Si us plau, torneu-ho a provar més tard.", + "serviceError": "Error del servei OpenAI Codex. Si us plau, torneu-ho a provar més tard.", + "genericError": "Error de l'API de Codex ({{status}})", + "noResponseBody": "Error de l'API de Codex: No hi ha cos de resposta", + "connectionFailed": "Ha fallat la connexió a l'API de Codex: {{message}}", + "unexpectedConnectionError": "Error inesperat en connectar amb l'API de Codex", + "apiError": "Error de l'API de Codex: {{message}}", + "responseFailed": "La resposta ha fallat: {{message}}", + "streamProcessingError": "Error en processar el flux de resposta: {{message}}", + "unexpectedStreamError": "Error inesperat en processar el flux de resposta", + "completionError": "Error de finalització d'OpenAI Codex: {{message}}" + }, "api": { - "invalidKeyInvalidChars": "La clau API conté caràcters no vàlids." + "invalidKeyInvalidChars": "La clau API conté caràcters no vàlids.", + "apiRequestFailed": "La sol·licitud API ha fallat ({{status}})" }, "manual_url_empty": "Si us plau, introdueix una URL de callback vàlida", "manual_url_no_query": "URL de callback no vàlida: falten paràmetres de consulta", diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index eaad479f4bb..71ec19dd395 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -141,6 +141,24 @@ "roo": { "authenticationRequired": "Poskytovatel Roo vyžaduje cloudovou autentizaci. Prosím přihlas se do Roo Code Cloud." }, + "openAiCodex": { + "notAuthenticated": "Nejsi přihlášen k OpenAI Codex. Prosím přihlas se pomocí OpenAI Codex OAuth.", + "invalidRequest": "Neplatný požadavek na Codex API. Zkontroluj prosím své vstupní parametry.", + "authenticationFailed": "Autentizace selhala. Prosím znovu se přihlas k OpenAI Codex.", + "accessDenied": "Přístup odepřen. Tvé předplatné ChatGPT možná nezahrnuje přístup k Codex.", + "endpointNotFound": "Endpoint Codex API nebyl nalezen.", + "rateLimitExceeded": "Překročen limit rychlosti. Zkus to prosím později.", + "serviceError": "Chyba služby OpenAI Codex. Zkus to prosím později.", + "genericError": "Chyba Codex API ({{status}})", + "noResponseBody": "Chyba Codex API: Žádné tělo odpovědi", + "connectionFailed": "Nepodařilo se připojit k Codex API: {{message}}", + "unexpectedConnectionError": "Neočekávaná chyba při připojování k Codex API", + "apiError": "Chyba Codex API: {{message}}", + "responseFailed": "Odpověď selhala: {{message}}", + "streamProcessingError": "Chyba při zpracování proudu odpovědi: {{message}}", + "unexpectedStreamError": "Neočekávaná chyba při zpracování proudu odpovědi", + "completionError": "Chyba dokončení OpenAI Codex: {{message}}" + }, "message": { "no_active_task_to_delete": "Nebyla nalezena aktivní úloha. Nelze smazat zprávy.", "invalid_timestamp_for_deletion": "Pro smazání byl poskytnut neplatný časový údaj.", @@ -156,7 +174,8 @@ "manual_url_auth_failed": "Ověření se nezdařilo", "manual_url_auth_error": "Došlo k chybě ověření", "api": { - "invalidKeyInvalidChars": "API klíč obsahuje neplatné znaky." + "invalidKeyInvalidChars": "API klíč obsahuje neplatné znaky.", + "apiRequestFailed": "API požadavek selhal ({{status}})" } }, "warnings": { diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index a4075f2c930..f46945057f6 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -144,8 +144,27 @@ "roo": { "authenticationRequired": "Roo-Anbieter erfordert Cloud-Authentifizierung. Bitte melde dich bei Roo Code Cloud an." }, + "openAiCodex": { + "notAuthenticated": "Nicht bei OpenAI Codex authentifiziert. Bitte melde dich über den OpenAI Codex OAuth-Flow an.", + "invalidRequest": "Ungültige Anfrage an die Codex-API. Bitte überprüfe deine Eingabeparameter.", + "authenticationFailed": "Authentifizierung fehlgeschlagen. Bitte authentifiziere dich erneut bei OpenAI Codex.", + "accessDenied": "Zugriff verweigert. Dein ChatGPT-Abonnement enthält möglicherweise keinen Codex-Zugang.", + "endpointNotFound": "Codex-API-Endpunkt nicht gefunden.", + "rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.", + "serviceError": "OpenAI Codex Dienstfehler. Bitte versuche es später erneut.", + "genericError": "Codex-API-Fehler ({{status}})", + "noResponseBody": "Codex-API-Fehler: Kein Antworttext", + "connectionFailed": "Verbindung zur Codex-API fehlgeschlagen: {{message}}", + "unexpectedConnectionError": "Unerwarteter Fehler beim Verbinden mit der Codex-API", + "apiError": "Codex-API-Fehler: {{message}}", + "responseFailed": "Antwort fehlgeschlagen: {{message}}", + "streamProcessingError": "Fehler beim Verarbeiten des Antwort-Streams: {{message}}", + "unexpectedStreamError": "Unerwarteter Fehler beim Verarbeiten des Antwort-Streams", + "completionError": "OpenAI Codex Vervollständigungsfehler: {{message}}" + }, "api": { - "invalidKeyInvalidChars": "API-Schlüssel enthält ungültige Zeichen." + "invalidKeyInvalidChars": "API-Schlüssel enthält ungültige Zeichen.", + "apiRequestFailed": "API-Anfrage fehlgeschlagen ({{status}})" }, "manual_url_empty": "Bitte gib eine gültige Callback-URL ein", "manual_url_no_query": "Ungültige Callback-URL: Query-Parameter fehlen", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f01a740cd07..59d9a198b13 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -144,8 +144,27 @@ "roo": { "authenticationRequired": "Roo provider requires cloud authentication. Please sign in to Roo Code Cloud." }, + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + }, "api": { - "invalidKeyInvalidChars": "API key contains invalid characters." + "invalidKeyInvalidChars": "API key contains invalid characters.", + "apiRequestFailed": "API request failed ({{status}})" }, "manual_url_empty": "Please enter a valid callback URL", "manual_url_no_query": "Invalid callback URL: missing query parameters", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 2c18dfd2db8..7b9faf728d3 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "El proveedor Roo requiere autenticación en la nube. Por favor, inicia sesión en Roo Code Cloud." }, "api": { - "invalidKeyInvalidChars": "La clave API contiene caracteres inválidos." + "invalidKeyInvalidChars": "La clave API contiene caracteres inválidos.", + "apiRequestFailed": "La solicitud API falló ({{status}})" }, "manual_url_empty": "Por favor, introduce una URL de callback válida", "manual_url_no_query": "URL de callback inválida: faltan parámetros de consulta", "manual_url_missing_params": "URL de callback inválida: faltan parámetros requeridos (code y state)", "manual_url_auth_failed": "Autenticación manual por URL falló", - "manual_url_auth_error": "Error de autenticación" + "manual_url_auth_error": "Error de autenticación", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "No hay contenido de terminal seleccionado", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 212a9c5b38d..eb0df84c012 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "Le fournisseur Roo nécessite une authentification cloud. Veuillez vous connecter à Roo Code Cloud." }, "api": { - "invalidKeyInvalidChars": "La clé API contient des caractères invalides." + "invalidKeyInvalidChars": "La clé API contient des caractères invalides.", + "apiRequestFailed": "La requête API a échoué ({{status}})" }, "manual_url_empty": "Veuillez entrer une URL de callback valide", "manual_url_no_query": "URL de callback invalide : paramètres de requête manquants", "manual_url_missing_params": "URL de callback invalide : paramètres requis manquants (code et state)", "manual_url_auth_failed": "Authentification par URL manuelle échouée", - "manual_url_auth_error": "Échec de l'authentification" + "manual_url_auth_error": "Échec de l'authentification", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "Aucun contenu de terminal sélectionné", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index b0ca14ff94c..ea0097c2146 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "Roo प्रदाता को क्लाउड प्रमाणीकरण की आवश्यकता है। कृपया Roo Code Cloud में साइन इन करें।" }, "api": { - "invalidKeyInvalidChars": "API कुंजी में अमान्य वर्ण हैं।" + "invalidKeyInvalidChars": "API कुंजी में अमान्य वर्ण हैं।", + "apiRequestFailed": "API अनुरोध विफल ({{status}})" }, "manual_url_empty": "कृपया एक वैध callback URL दर्ज करें", "manual_url_no_query": "अवैध callback URL: क्वेरी पैरामीटर गुम हैं", "manual_url_missing_params": "अवैध callback URL: आवश्यक पैरामीटर गुम हैं (code और state)", "manual_url_auth_failed": "मैनुअल URL प्रमाणीकरण असफल", - "manual_url_auth_error": "प्रमाणीकरण असफल" + "manual_url_auth_error": "प्रमाणीकरण असफल", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "कोई टर्मिनल सामग्री चयनित नहीं", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index 0dd7361cdd6..28d34014cd8 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "Penyedia Roo memerlukan autentikasi cloud. Silakan masuk ke Roo Code Cloud." }, "api": { - "invalidKeyInvalidChars": "Kunci API mengandung karakter tidak valid." + "invalidKeyInvalidChars": "Kunci API mengandung karakter tidak valid.", + "apiRequestFailed": "Permintaan API gagal ({{status}})" }, "manual_url_empty": "Silakan masukkan URL callback yang valid", "manual_url_no_query": "URL callback tidak valid: parameter query hilang", "manual_url_missing_params": "URL callback tidak valid: parameter yang diperlukan hilang (code dan state)", "manual_url_auth_failed": "Autentikasi URL manual gagal", - "manual_url_auth_error": "Autentikasi gagal" + "manual_url_auth_error": "Autentikasi gagal", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "Tidak ada konten terminal yang dipilih", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 1ec7ac20f84..3a635b90289 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "Il provider Roo richiede l'autenticazione cloud. Accedi a Roo Code Cloud." }, "api": { - "invalidKeyInvalidChars": "La chiave API contiene caratteri non validi." + "invalidKeyInvalidChars": "La chiave API contiene caratteri non validi.", + "apiRequestFailed": "Richiesta API fallita ({{status}})" }, "manual_url_empty": "Inserisci un URL di callback valido", "manual_url_no_query": "URL di callback non valido: parametri di query mancanti", "manual_url_missing_params": "URL di callback non valido: parametri richiesti mancanti (code e state)", "manual_url_auth_failed": "Autenticazione manuale tramite URL fallita", - "manual_url_auth_error": "Autenticazione fallita" + "manual_url_auth_error": "Autenticazione fallita", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "Nessun contenuto del terminale selezionato", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index a6fcd46cd84..42f50bc1221 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "Rooプロバイダーはクラウド認証が必要です。Roo Code Cloudにサインインしてください。" }, "api": { - "invalidKeyInvalidChars": "APIキーに無効な文字が含まれています。" + "invalidKeyInvalidChars": "APIキーに無効な文字が含まれています。", + "apiRequestFailed": "APIリクエストが失敗しました ({{status}})" }, "manual_url_empty": "有効なコールバック URL を入力してください", "manual_url_no_query": "無効なコールバック URL:クエリパラメータがありません", "manual_url_missing_params": "無効なコールバック URL:必要なパラメータ(code と state)がありません", "manual_url_auth_failed": "手動 URL 認証が失敗しました", - "manual_url_auth_error": "認証に失敗しました" + "manual_url_auth_error": "認証に失敗しました", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "選択されたターミナルコンテンツがありません", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 31be06f652a..32de156d6c5 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "Roo 제공업체는 클라우드 인증이 필요합니다. Roo Code Cloud에 로그인하세요." }, "api": { - "invalidKeyInvalidChars": "API 키에 유효하지 않은 문자가 포함되어 있습니다." + "invalidKeyInvalidChars": "API 키에 유효하지 않은 문자가 포함되어 있습니다.", + "apiRequestFailed": "API 요청 실패 ({{status}})" }, "manual_url_empty": "유효한 콜백 URL을 입력하세요", "manual_url_no_query": "유효하지 않은 콜백 URL: 쿼리 매개변수 누락", "manual_url_missing_params": "유효하지 않은 콜백 URL: 필요한 매개변수 누락 (code와 state)", "manual_url_auth_failed": "수동 URL 인증 실패", - "manual_url_auth_error": "인증 실패" + "manual_url_auth_error": "인증 실패", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "선택된 터미널 내용이 없습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 9e69c0c369e..dbbcad8b161 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "Roo provider vereist cloud authenticatie. Log in bij Roo Code Cloud." }, "api": { - "invalidKeyInvalidChars": "API-sleutel bevat ongeldige karakters." + "invalidKeyInvalidChars": "API-sleutel bevat ongeldige karakters.", + "apiRequestFailed": "API-verzoek mislukt ({{status}})" }, "manual_url_empty": "Voer een geldige callback-URL in", "manual_url_no_query": "Ongeldige callback-URL: query-parameters ontbreken", "manual_url_missing_params": "Ongeldige callback-URL: vereiste parameters ontbreken (code en state)", "manual_url_auth_failed": "Handmatige URL-authenticatie mislukt", - "manual_url_auth_error": "Authenticatie mislukt" + "manual_url_auth_error": "Authenticatie mislukt", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "Geen terminalinhoud geselecteerd", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 06afd8db357..41987c839b8 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "Dostawca Roo wymaga uwierzytelnienia w chmurze. Zaloguj się do Roo Code Cloud." }, "api": { - "invalidKeyInvalidChars": "Klucz API zawiera nieprawidłowe znaki." + "invalidKeyInvalidChars": "Klucz API zawiera nieprawidłowe znaki.", + "apiRequestFailed": "Żądanie API nie powiodło się ({{status}})" }, "manual_url_empty": "Wprowadź prawidłowy URL callback", "manual_url_no_query": "Nieprawidłowy URL callback: brak parametrów zapytania", "manual_url_missing_params": "Nieprawidłowy URL callback: brak wymaganych parametrów (code i state)", "manual_url_auth_failed": "Ręczne uwierzytelnienie URL nie powiodło się", - "manual_url_auth_error": "Uwierzytelnienie nie powiodło się" + "manual_url_auth_error": "Uwierzytelnienie nie powiodło się", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "Nie wybrano zawartości terminala", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 2f127169949..45171421960 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -149,13 +149,32 @@ "authenticationRequired": "O provedor Roo requer autenticação na nuvem. Faça login no Roo Code Cloud." }, "api": { - "invalidKeyInvalidChars": "A chave API contém caracteres inválidos." + "invalidKeyInvalidChars": "A chave API contém caracteres inválidos.", + "apiRequestFailed": "Solicitação API falhou ({{status}})" }, "manual_url_empty": "Por favor, insira uma URL de callback válida", "manual_url_no_query": "URL de callback inválida: parâmetros de consulta ausentes", "manual_url_missing_params": "URL de callback inválida: parâmetros obrigatórios ausentes (code e state)", "manual_url_auth_failed": "Autenticação manual por URL falhou", - "manual_url_auth_error": "Falha na autenticação" + "manual_url_auth_error": "Falha na autenticação", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "Nenhum conteúdo do terminal selecionado", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 1eb9902ad47..01d7baf0aa0 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "Провайдер Roo требует облачной аутентификации. Войдите в Roo Code Cloud." }, "api": { - "invalidKeyInvalidChars": "API-ключ содержит недопустимые символы." + "invalidKeyInvalidChars": "API-ключ содержит недопустимые символы.", + "apiRequestFailed": "Запрос API не удался ({{status}})" }, "manual_url_empty": "Введи действительный URL обратного вызова", "manual_url_no_query": "Недействительный URL обратного вызова: отсутствуют параметры запроса", "manual_url_missing_params": "Недействительный URL обратного вызова: отсутствуют обязательные параметры (code и state)", "manual_url_auth_failed": "Ручная аутентификация по URL не удалась", - "manual_url_auth_error": "Аутентификация не удалась" + "manual_url_auth_error": "Аутентификация не удалась", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "Не выбрано содержимое терминала", diff --git a/src/i18n/locales/th/common.json b/src/i18n/locales/th/common.json index 49f16ed26fd..eea4be9e0df 100644 --- a/src/i18n/locales/th/common.json +++ b/src/i18n/locales/th/common.json @@ -141,6 +141,24 @@ "roo": { "authenticationRequired": "ผู้ให้บริการ Roo ต้องการการยืนยันตัวตนบนคลาวด์ กรุณาเข้าสู่ระบบ Roo Code Cloud" }, + "openAiCodex": { + "notAuthenticated": "ยังไม่ได้ยืนยันตัวตนกับ OpenAI Codex กรุณาลงชื่อเข้าใช้โดยใช้กระบวนการ OAuth ของ OpenAI Codex", + "invalidRequest": "คำขอไปยัง Codex API ไม่ถูกต้อง กรุณาตรวจสอบพารามิเตอร์ที่ป้อน", + "authenticationFailed": "การยืนยันตัวตนล้มเหลว กรุณายืนยันตัวตนใหม่กับ OpenAI Codex", + "accessDenied": "การเข้าถึงถูกปฏิเสธ การสมัครสมาชิก ChatGPT ของคุณอาจไม่รวมการเข้าถึง Codex", + "endpointNotFound": "ไม่พบ endpoint ของ Codex API", + "rateLimitExceeded": "เกินขีดจำกัดอัตรา กรุณาลองอีกครั้งในภายหลัง", + "serviceError": "ข้อผิดพลาดบริการ OpenAI Codex กรุณาลองอีกครั้งในภายหลัง", + "genericError": "ข้อผิดพลาด Codex API ({{status}})", + "noResponseBody": "ข้อผิดพลาด Codex API: ไม่มีเนื้อหาการตอบกลับ", + "connectionFailed": "ล้มเหลวในการเชื่อมต่อกับ Codex API: {{message}}", + "unexpectedConnectionError": "เกิดข้อผิดพลาดที่ไม่คาดคิดในการเชื่อมต่อกับ Codex API", + "apiError": "ข้อผิดพลาด Codex API: {{message}}", + "responseFailed": "การตอบกลับล้มเหลว: {{message}}", + "streamProcessingError": "เกิดข้อผิดพลาดในการประมวลผลสตรีมการตอบกลับ: {{message}}", + "unexpectedStreamError": "เกิดข้อผิดพลาดที่ไม่คาดคิดในการประมวลผลสตรีมการตอบกลับ", + "completionError": "ข้อผิดพลาดการเติมเต็ม OpenAI Codex: {{message}}" + }, "message": { "no_active_task_to_delete": "ไม่พบงานที่ใช้งานอยู่ ไม่สามารถลบข้อความได้", "invalid_timestamp_for_deletion": "ระบุ timestamp ที่ไม่ถูกต้องสำหรับการลบ", @@ -156,7 +174,8 @@ "manual_url_auth_failed": "การยืนยันตัวตนล้มเหลว", "manual_url_auth_error": "เกิดข้อผิดพลาดในการยืนยันตัวตน", "api": { - "invalidKeyInvalidChars": "API key มีตัวอักษรที่ไม่ถูกต้อง" + "invalidKeyInvalidChars": "API key มีตัวอักษรที่ไม่ถูกต้อง", + "apiRequestFailed": "คำขอ API ล้มเหลว ({{status}})" } }, "warnings": { diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 86e6ed417d3..346c088d7bd 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "Roo sağlayıcısı bulut kimlik doğrulaması gerektirir. Lütfen Roo Code Cloud'a giriş yapın." }, "api": { - "invalidKeyInvalidChars": "API anahtarı geçersiz karakterler içeriyor." + "invalidKeyInvalidChars": "API anahtarı geçersiz karakterler içeriyor.", + "apiRequestFailed": "API isteği başarısız oldu ({{status}})" }, "manual_url_empty": "Lütfen geçerli bir callback URL'si girin", "manual_url_no_query": "Geçersiz callback URL'si: sorgu parametreleri eksik", "manual_url_missing_params": "Geçersiz callback URL'si: gerekli parametreler eksik (code ve state)", "manual_url_auth_failed": "Manuel URL kimlik doğrulama başarısız", - "manual_url_auth_error": "Kimlik doğrulama başarısız" + "manual_url_auth_error": "Kimlik doğrulama başarısız", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "Seçili terminal içeriği yok", diff --git a/src/i18n/locales/uk/common.json b/src/i18n/locales/uk/common.json index 7dc6b8f0812..0f166fa554e 100644 --- a/src/i18n/locales/uk/common.json +++ b/src/i18n/locales/uk/common.json @@ -141,6 +141,24 @@ "roo": { "authenticationRequired": "Провайдер Roo вимагає автентифікації в хмарі. Будь ласка, увійди в Roo Code Cloud." }, + "openAiCodex": { + "notAuthenticated": "Не автентифіковано з OpenAI Codex. Будь ласка, увійди, використовуючи OAuth потік OpenAI Codex.", + "invalidRequest": "Недійсний запит до Codex API. Будь ласка, перевір параметри вводу.", + "authenticationFailed": "Автентифікація не вдалася. Будь ласка, повторно автентифікуйся з OpenAI Codex.", + "accessDenied": "Доступ заборонено. Твоя підписка ChatGPT може не включати доступ до Codex.", + "endpointNotFound": "Кінцеву точку Codex API не знайдено.", + "rateLimitExceeded": "Перевищено ліміт швидкості. Будь ласка, спробуй пізніше.", + "serviceError": "Помилка сервісу OpenAI Codex. Будь ласка, спробуй пізніше.", + "genericError": "Помилка Codex API ({{status}})", + "noResponseBody": "Помилка Codex API: Немає тіла відповіді", + "connectionFailed": "Не вдалося підключитися до Codex API: {{message}}", + "unexpectedConnectionError": "Неочікувана помилка підключення до Codex API", + "apiError": "Помилка Codex API: {{message}}", + "responseFailed": "Відповідь не вдалася: {{message}}", + "streamProcessingError": "Помилка обробки потоку відповіді: {{message}}", + "unexpectedStreamError": "Неочікувана помилка обробки потоку відповіді", + "completionError": "Помилка завершення OpenAI Codex: {{message}}" + }, "message": { "no_active_task_to_delete": "Не знайдено активного завдання. Неможливо видалити повідомлення.", "invalid_timestamp_for_deletion": "Надано недійсний timestamp для видалення.", @@ -156,7 +174,8 @@ "manual_url_auth_failed": "Автентифікація не вдалася", "manual_url_auth_error": "Сталася помилка автентифікації", "api": { - "invalidKeyInvalidChars": "API ключ містить недопустимі символи." + "invalidKeyInvalidChars": "API ключ містить недопустимі символи.", + "apiRequestFailed": "Запит до API не вдався ({{status}})" } }, "warnings": { diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 286298eb2b8..ef57771c82d 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -145,13 +145,32 @@ "authenticationRequired": "Nhà cung cấp Roo yêu cầu xác thực đám mây. Vui lòng đăng nhập vào Roo Code Cloud." }, "api": { - "invalidKeyInvalidChars": "Khóa API chứa ký tự không hợp lệ." + "invalidKeyInvalidChars": "Khóa API chứa ký tự không hợp lệ.", + "apiRequestFailed": "Yêu cầu API thất bại ({{status}})" }, "manual_url_empty": "Vui lòng nhập URL callback hợp lệ", "manual_url_no_query": "URL callback không hợp lệ: thiếu tham số truy vấn", "manual_url_missing_params": "URL callback không hợp lệ: thiếu tham số bắt buộc (code và state)", "manual_url_auth_failed": "Xác thực URL thủ công thất bại", - "manual_url_auth_error": "Xác thực thất bại" + "manual_url_auth_error": "Xác thực thất bại", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "Không có nội dung terminal được chọn", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 735d5558420..f7c2bbd15c4 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -150,13 +150,32 @@ "authenticationRequired": "Roo 提供商需要云认证。请登录 Roo Code Cloud。" }, "api": { - "invalidKeyInvalidChars": "API 密钥包含无效字符。" + "invalidKeyInvalidChars": "API 密钥包含无效字符.", + "apiRequestFailed": "API 请求失败 ({{status}})" }, "manual_url_empty": "请输入有效的回调 URL", "manual_url_no_query": "无效的回调 URL:缺少查询参数", "manual_url_missing_params": "无效的回调 URL:缺少必需参数(code 和 state)", "manual_url_auth_failed": "手动 URL 身份验证失败", - "manual_url_auth_error": "身份验证失败" + "manual_url_auth_error": "身份验证失败", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "没有选择终端内容", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 964bc6098eb..9becab5d5ed 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -144,14 +144,33 @@ "authenticationRequired": "Roo 提供者需要雲端認證。請登入 Roo Code Cloud。" }, "api": { - "invalidKeyInvalidChars": "API 金鑰包含無效字元。" + "invalidKeyInvalidChars": "API 金鑰包含無效字元。", + "apiRequestFailed": "API 請求失敗 ({{status}})" }, "manual_url_empty": "請輸入有效的回呼 URL", "manual_url_no_query": "無效的回呼 URL:缺少查詢參數", "manual_url_missing_params": "無效的回呼 URL:缺少必要參數(code 和 state)", "manual_url_auth_failed": "手動 URL 身份驗證失敗", "manual_url_auth_error": "身份驗證失敗", - "mode_import_failed": "匯入模式失敗:{{error}}" + "mode_import_failed": "匯入模式失敗:{{error}}", + "openAiCodex": { + "notAuthenticated": "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + "invalidRequest": "Invalid request to Codex API. Please check your input parameters.", + "authenticationFailed": "Authentication failed. Please re-authenticate with OpenAI Codex.", + "accessDenied": "Access denied. Your ChatGPT subscription may not include Codex access.", + "endpointNotFound": "Codex API endpoint not found.", + "rateLimitExceeded": "Rate limit exceeded. Please try again later.", + "serviceError": "OpenAI Codex service error. Please try again later.", + "genericError": "Codex API error ({{status}})", + "noResponseBody": "Codex API error: No response body", + "connectionFailed": "Failed to connect to Codex API: {{message}}", + "unexpectedConnectionError": "Unexpected error connecting to Codex API", + "apiError": "Codex API error: {{message}}", + "responseFailed": "Response failed: {{message}}", + "streamProcessingError": "Error processing response stream: {{message}}", + "unexpectedStreamError": "Unexpected error processing response stream", + "completionError": "OpenAI Codex completion error: {{message}}" + } }, "warnings": { "no_terminal_content": "沒有選擇終端機內容", diff --git a/src/integrations/openai-codex/oauth.ts b/src/integrations/openai-codex/oauth.ts new file mode 100644 index 00000000000..36b489376bf --- /dev/null +++ b/src/integrations/openai-codex/oauth.ts @@ -0,0 +1,740 @@ +import * as crypto from "crypto" +import * as http from "http" +import { URL } from "url" +import type { ExtensionContext } from "vscode" +import { z } from "zod" + +/** + * OpenAI Codex OAuth Configuration + * + * Based on the OpenAI Codex OAuth implementation guide: + * - ISSUER: https://auth.openai.com + * - Authorization endpoint: https://auth.openai.com/oauth/authorize + * - Token endpoint: https://auth.openai.com/oauth/token + * - Fixed callback port: 1455 + * - Codex-specific params: codex_cli_simplified_flow=true, originator=kilo-code + */ +export const OPENAI_CODEX_OAUTH_CONFIG = { + authorizationEndpoint: "https://auth.openai.com/oauth/authorize", + tokenEndpoint: "https://auth.openai.com/oauth/token", + clientId: "app_EMoamEEZ73f0CkXaXp7hrann", + redirectUri: "http://localhost:1455/auth/callback", + scopes: "openid profile email offline_access", + callbackPort: 1455, +} as const + +// Token storage key +const OPENAI_CODEX_CREDENTIALS_KEY = "openai-codex-oauth-credentials" + +// Credentials schema +const openAiCodexCredentialsSchema = z.object({ + type: z.literal("openai-codex"), + access_token: z.string().min(1), + refresh_token: z.string().min(1), + // expires is in milliseconds since epoch + expires: z.number(), + email: z.string().optional(), + // ChatGPT account ID extracted from JWT claims (for ChatGPT-Account-Id header) + accountId: z.string().optional(), +}) + +export type OpenAiCodexCredentials = z.infer + +// Token response schema from OpenAI +const tokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string().min(1).optional(), + id_token: z.string().optional(), + expires_in: z.number(), + email: z.string().optional(), + token_type: z.string().optional(), +}) + +/** + * JWT claims structure for extracting ChatGPT account ID + */ +interface IdTokenClaims { + chatgpt_account_id?: string + organizations?: Array<{ id: string }> + email?: string + "https://api.openai.com/auth"?: { + chatgpt_account_id?: string + } +} + +/** + * Parse JWT claims from a token + * Returns undefined if the token is invalid or cannot be parsed + */ +function parseJwtClaims(token: string): IdTokenClaims | undefined { + const parts = token.split(".") + if (parts.length !== 3) return undefined + try { + // Use base64url decoding (Node.js Buffer handles this) + const payload = Buffer.from(parts[1], "base64url").toString("utf-8") + return JSON.parse(payload) as IdTokenClaims + } catch { + return undefined + } +} + +/** + * Extract ChatGPT account ID from JWT claims + * Checks multiple locations: + * 1. Root-level chatgpt_account_id + * 2. Nested under https://api.openai.com/auth + * 3. First organization ID + */ +function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined { + return ( + claims.chatgpt_account_id || + claims["https://api.openai.com/auth"]?.chatgpt_account_id || + claims.organizations?.[0]?.id + ) +} + +/** + * Extract ChatGPT account ID from token response + * Tries id_token first, then access_token + */ +function extractAccountId(tokens: { id_token?: string; access_token: string }): string | undefined { + // Try id_token first (more reliable source) + if (tokens.id_token) { + const claims = parseJwtClaims(tokens.id_token) + const accountId = claims && extractAccountIdFromClaims(claims) + if (accountId) return accountId + } + // Fall back to access_token + if (tokens.access_token) { + const claims = parseJwtClaims(tokens.access_token) + return claims ? extractAccountIdFromClaims(claims) : undefined + } + return undefined +} + +class OpenAiCodexOAuthTokenError extends Error { + public readonly status?: number + public readonly errorCode?: string + + constructor(message: string, opts?: { status?: number; errorCode?: string }) { + super(message) + this.name = "OpenAiCodexOAuthTokenError" + this.status = opts?.status + this.errorCode = opts?.errorCode + } + + public isLikelyInvalidGrant(): boolean { + if (this.errorCode && /invalid_grant/i.test(this.errorCode)) { + return true + } + if (this.status === 400 || this.status === 401 || this.status === 403) { + return /invalid_grant|revoked|expired|invalid refresh/i.test(this.message) + } + return false + } +} + +function parseOAuthErrorDetails(errorText: string): { errorCode?: string; errorMessage?: string } { + try { + const json: unknown = JSON.parse(errorText) + if (!json || typeof json !== "object") { + return {} + } + + const obj = json as Record + const errorField = obj.error + + const errorCode: string | undefined = + typeof errorField === "string" + ? errorField + : errorField && + typeof errorField === "object" && + typeof (errorField as Record).type === "string" + ? ((errorField as Record).type as string) + : undefined + + const errorDescription = obj.error_description + const errorMessageFromError = + errorField && typeof errorField === "object" ? (errorField as Record).message : undefined + + const errorMessage: string | undefined = + typeof errorDescription === "string" + ? errorDescription + : typeof errorMessageFromError === "string" + ? errorMessageFromError + : typeof obj.message === "string" + ? obj.message + : undefined + + return { errorCode, errorMessage } + } catch { + return {} + } +} + +/** + * Generates a cryptographically random PKCE code verifier + * Must be 43-128 characters long using unreserved characters + */ +export function generateCodeVerifier(): string { + const buffer = crypto.randomBytes(32) + return buffer.toString("base64url") +} + +/** + * Generates the PKCE code challenge from the verifier using S256 method + */ +export function generateCodeChallenge(verifier: string): string { + const hash = crypto.createHash("sha256").update(verifier).digest() + return hash.toString("base64url") +} + +/** + * Generates a random state parameter for CSRF protection + */ +export function generateState(): string { + return crypto.randomBytes(16).toString("hex") +} + +/** + * Builds the authorization URL for OpenAI Codex OAuth flow + * Includes Codex-specific parameters per the implementation guide + */ +export function buildAuthorizationUrl(codeChallenge: string, state: string): string { + const params = new URLSearchParams({ + client_id: OPENAI_CODEX_OAUTH_CONFIG.clientId, + redirect_uri: OPENAI_CODEX_OAUTH_CONFIG.redirectUri, + scope: OPENAI_CODEX_OAUTH_CONFIG.scopes, + code_challenge: codeChallenge, + code_challenge_method: "S256", + response_type: "code", + state, + // Codex-specific parameters + codex_cli_simplified_flow: "true", + originator: "kilo-code", + }) + + return `${OPENAI_CODEX_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}` +} + +/** + * Exchanges the authorization code for tokens + * Important: Uses application/x-www-form-urlencoded (not JSON) + * Important: state must NOT be included in token exchange body + */ +export async function exchangeCodeForTokens(code: string, codeVerifier: string): Promise { + // Per the implementation guide: use application/x-www-form-urlencoded + // and do NOT include state in the body (OpenAI returns error if included) + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: OPENAI_CODEX_OAUTH_CONFIG.clientId, + code, + redirect_uri: OPENAI_CODEX_OAUTH_CONFIG.redirectUri, + code_verifier: codeVerifier, + }) + + const response = await fetch(OPENAI_CODEX_OAUTH_CONFIG.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + const data = await response.json() + const tokenResponse = tokenResponseSchema.parse(data) + + if (!tokenResponse.refresh_token) { + throw new Error("Token exchange did not return a refresh_token") + } + + // Per the implementation guide: expires is in milliseconds since epoch + const expiresAt = Date.now() + tokenResponse.expires_in * 1000 + + // Extract ChatGPT account ID from JWT claims + const accountId = extractAccountId({ + id_token: tokenResponse.id_token, + access_token: tokenResponse.access_token, + }) + + return { + type: "openai-codex", + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token, + expires: expiresAt, + email: tokenResponse.email, + accountId, + } +} + +/** + * Refreshes the access token using the refresh token + * Uses application/x-www-form-urlencoded (not JSON) + */ +export async function refreshAccessToken(credentials: OpenAiCodexCredentials): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: OPENAI_CODEX_OAUTH_CONFIG.clientId, + refresh_token: credentials.refresh_token, + }) + + const response = await fetch(OPENAI_CODEX_OAUTH_CONFIG.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const errorText = await response.text() + const { errorCode, errorMessage } = parseOAuthErrorDetails(errorText) + const details = errorMessage ? errorMessage : errorText + throw new OpenAiCodexOAuthTokenError( + `Token refresh failed: ${response.status} ${response.statusText}${details ? ` - ${details}` : ""}`, + { status: response.status, errorCode }, + ) + } + + const data = await response.json() + const tokenResponse = tokenResponseSchema.parse(data) + + // Per the implementation guide: expires is in milliseconds since epoch + const expiresAt = Date.now() + tokenResponse.expires_in * 1000 + + // Extract new account ID from refreshed tokens, or preserve existing one + const newAccountId = extractAccountId({ + id_token: tokenResponse.id_token, + access_token: tokenResponse.access_token, + }) + + return { + type: "openai-codex", + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token ?? credentials.refresh_token, + expires: expiresAt, + email: tokenResponse.email ?? credentials.email, + // Prefer newly extracted accountId, fall back to existing + accountId: newAccountId ?? credentials.accountId, + } +} + +/** + * Checks if the credentials are expired (with 5 minute buffer) + * Per the implementation guide: expires is in milliseconds since epoch + */ +export function isTokenExpired(credentials: OpenAiCodexCredentials): boolean { + const bufferMs = 5 * 60 * 1000 // 5 minutes buffer + return Date.now() >= credentials.expires - bufferMs +} + +/** + * OpenAiCodexOAuthManager - Handles OAuth flow and token management + */ +export class OpenAiCodexOAuthManager { + private context: ExtensionContext | null = null + private credentials: OpenAiCodexCredentials | null = null + private logFn: ((message: string) => void) | null = null + private refreshPromise: Promise | null = null + private pendingAuth: { + codeVerifier: string + state: string + server?: http.Server + } | null = null + + private log(message: string): void { + if (this.logFn) { + this.logFn(message) + } else { + console.log(message) + } + } + + private logError(message: string, error?: unknown): void { + const details = error instanceof Error ? error.message : error !== undefined ? String(error) : undefined + const full = details ? `${message} ${details}` : message + this.log(full) + console.error(full) + } + + /** + * Initialize the OAuth manager with VS Code extension context + */ + initialize(context: ExtensionContext, logFn?: (message: string) => void): void { + this.context = context + this.logFn = logFn ?? null + } + + /** + * Force a refresh using the stored refresh token even if the access token is not expired. + * Useful when the server invalidates an access token early. + */ + async forceRefreshAccessToken(): Promise { + if (!this.credentials) { + await this.loadCredentials() + } + + if (!this.credentials) { + return null + } + + try { + // De-dupe concurrent refreshes + if (!this.refreshPromise) { + const prevRefreshToken = this.credentials.refresh_token + this.log(`[openai-codex-oauth] Forcing token refresh (expires=${this.credentials.expires})...`) + this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { + const rotated = newCreds.refresh_token !== prevRefreshToken + this.log( + `[openai-codex-oauth] Forced refresh response received (expires_in≈${Math.round( + (newCreds.expires - Date.now()) / 1000, + )}s, refresh_token_rotated=${rotated})`, + ) + return newCreds + }) + } + + const newCredentials = await this.refreshPromise + this.refreshPromise = null + await this.saveCredentials(newCredentials) + this.log(`[openai-codex-oauth] Forced token persisted (expires=${newCredentials.expires})`) + return newCredentials.access_token + } catch (error) { + this.refreshPromise = null + this.logError("[openai-codex-oauth] Failed to force refresh token:", error) + if (error instanceof OpenAiCodexOAuthTokenError && error.isLikelyInvalidGrant()) { + this.log("[openai-codex-oauth] Refresh token appears invalid; clearing stored credentials") + await this.clearCredentials() + } + return null + } + } + + /** + * Load credentials from storage + */ + async loadCredentials(): Promise { + if (!this.context) { + return null + } + + try { + const credentialsJson = await this.context.secrets.get(OPENAI_CODEX_CREDENTIALS_KEY) + if (!credentialsJson) { + return null + } + + const parsed = JSON.parse(credentialsJson) + this.credentials = openAiCodexCredentialsSchema.parse(parsed) + return this.credentials + } catch (error) { + this.logError("[openai-codex-oauth] Failed to load credentials:", error) + return null + } + } + + /** + * Save credentials to storage + */ + async saveCredentials(credentials: OpenAiCodexCredentials): Promise { + if (!this.context) { + throw new Error("OAuth manager not initialized") + } + + await this.context.secrets.store(OPENAI_CODEX_CREDENTIALS_KEY, JSON.stringify(credentials)) + this.credentials = credentials + } + + /** + * Clear credentials from storage + */ + async clearCredentials(): Promise { + if (!this.context) { + return + } + + await this.context.secrets.delete(OPENAI_CODEX_CREDENTIALS_KEY) + this.credentials = null + } + + /** + * Get a valid access token, refreshing if necessary + */ + async getAccessToken(): Promise { + // Try to load credentials if not already loaded + if (!this.credentials) { + await this.loadCredentials() + } + + if (!this.credentials) { + return null + } + + // Check if token is expired and refresh if needed + if (isTokenExpired(this.credentials)) { + try { + // De-dupe concurrent refreshes + if (!this.refreshPromise) { + this.log( + `[openai-codex-oauth] Access token expired (expires=${this.credentials.expires}). Refreshing...`, + ) + const prevRefreshToken = this.credentials.refresh_token + this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { + const rotated = newCreds.refresh_token !== prevRefreshToken + this.log( + `[openai-codex-oauth] Refresh response received (expires_in≈${Math.round( + (newCreds.expires - Date.now()) / 1000, + )}s, refresh_token_rotated=${rotated})`, + ) + return newCreds + }) + } + + const newCredentials = await this.refreshPromise + this.refreshPromise = null + await this.saveCredentials(newCredentials) + this.log(`[openai-codex-oauth] Token persisted (expires=${newCredentials.expires})`) + } catch (error) { + this.refreshPromise = null + this.logError("[openai-codex-oauth] Failed to refresh token:", error) + + // Only clear secrets when the refresh token is clearly invalid/revoked. + if (error instanceof OpenAiCodexOAuthTokenError && error.isLikelyInvalidGrant()) { + this.log("[openai-codex-oauth] Refresh token appears invalid; clearing stored credentials") + await this.clearCredentials() + } + return null + } + } + + return this.credentials.access_token + } + + /** + * Get the user's email from credentials + */ + async getEmail(): Promise { + if (!this.credentials) { + await this.loadCredentials() + } + return this.credentials?.email || null + } + + /** + * Get the ChatGPT account ID from credentials + * Used for the ChatGPT-Account-Id header required by the Codex API + */ + async getAccountId(): Promise { + if (!this.credentials) { + await this.loadCredentials() + } + return this.credentials?.accountId || null + } + + /** + * Check if the user is authenticated + */ + async isAuthenticated(): Promise { + const token = await this.getAccessToken() + return token !== null + } + + /** + * Start the OAuth authorization flow + * Returns the authorization URL to open in browser + */ + startAuthorizationFlow(): string { + // Cancel any existing authorization flow before starting a new one + this.cancelAuthorizationFlow() + + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const state = generateState() + + this.pendingAuth = { + codeVerifier, + state, + } + + return buildAuthorizationUrl(codeChallenge, state) + } + + /** + * Start a local server to receive the OAuth callback + * Returns a promise that resolves when authentication is complete + */ + async waitForCallback(): Promise { + if (!this.pendingAuth) { + throw new Error("No pending authorization flow") + } + + // Close any existing server before starting a new one + if (this.pendingAuth.server) { + try { + this.pendingAuth.server.close() + } catch { + // Ignore errors when closing + } + this.pendingAuth.server = undefined + } + + return new Promise((resolve, reject) => { + const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url || "", `http://localhost:${OPENAI_CODEX_OAUTH_CONFIG.callbackPort}`) + + if (url.pathname !== "/auth/callback") { + res.writeHead(404) + res.end("Not Found") + return + } + + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const error = url.searchParams.get("error") + + if (error) { + res.writeHead(400) + res.end(`Authentication failed: ${error}`) + reject(new Error(`OAuth error: ${error}`)) + server.close() + return + } + + if (!code || !state) { + res.writeHead(400) + res.end("Missing code or state parameter") + reject(new Error("Missing code or state parameter")) + server.close() + return + } + + if (state !== this.pendingAuth?.state) { + res.writeHead(400) + res.end("State mismatch - possible CSRF attack") + reject(new Error("State mismatch")) + server.close() + return + } + + try { + // Note: state is validated above but not passed to exchangeCodeForTokens + // per the implementation guide (OpenAI rejects it) + const credentials = await exchangeCodeForTokens(code, this.pendingAuth.codeVerifier) + + await this.saveCredentials(credentials) + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + res.end(` + + + +Authentication Successful + + + +
+

✓ Authentication Successful

+

You can close this window and return to VS Code.

+
+ + +`) + + this.pendingAuth = null + server.close() + resolve(credentials) + } catch (exchangeError) { + res.writeHead(500) + res.end(`Token exchange failed: ${exchangeError}`) + reject(exchangeError) + server.close() + } + } catch (err) { + res.writeHead(500) + res.end("Internal server error") + reject(err) + server.close() + } + }) + + server.on("error", (err: NodeJS.ErrnoException) => { + this.pendingAuth = null + if (err.code === "EADDRINUSE") { + reject( + new Error( + `Port ${OPENAI_CODEX_OAUTH_CONFIG.callbackPort} is already in use. ` + + `Please close any other applications using this port and try again.`, + ), + ) + } else { + reject(err) + } + }) + + // Set a timeout for the callback + const timeout = setTimeout( + () => { + server.close() + reject(new Error("Authentication timed out")) + }, + 5 * 60 * 1000, + ) // 5 minutes + + server.listen(OPENAI_CODEX_OAUTH_CONFIG.callbackPort, () => { + if (this.pendingAuth) { + this.pendingAuth.server = server + } + }) + + // Clear timeout when server closes + server.on("close", () => { + clearTimeout(timeout) + }) + }) + } + + /** + * Cancel any pending authorization flow + */ + cancelAuthorizationFlow(): void { + if (this.pendingAuth?.server) { + this.pendingAuth.server.close() + } + this.pendingAuth = null + } + + /** + * Get the current credentials (for display purposes) + */ + getCredentials(): OpenAiCodexCredentials | null { + return this.credentials + } +} + +// Singleton instance +export const openAiCodexOAuthManager = new OpenAiCodexOAuthManager() diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 79a9e6628dc..d187e6c73dc 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -1,3 +1,4 @@ +import { z } from "zod" import type { GlobalSettings, ProviderSettingsEntry, @@ -16,7 +17,11 @@ import type { ShareVisibility, QueuedMessage, SerializedCustomToolDefinition, + InstallMarketplaceItemOptions, + RooCodeSettings, + PromptComponent, } from "@roo-code/types" +import { marketplaceItemSchema } from "@roo-code/types" import { GitCommit } from "../utils/git" @@ -553,11 +558,350 @@ export type ExtensionState = Pick< showTimestamps?: boolean // kilocode_change: Show timestamps in chat messages showDiffStats?: boolean // kilocode_change: Show diff stats in task header claudeCodeIsAuthenticated?: boolean + openAiCodexIsAuthenticated?: boolean debug?: boolean speechToTextStatus?: { available: boolean; reason?: "openaiKeyMissing" | "ffmpegNotInstalled" } // kilocode_change: Speech-to-text availability status with failure reason appendSystemPrompt?: string // kilocode_change: Custom text to append to system prompt (CLI only) } +export interface Command { + name: string + source: "global" | "project" | "built-in" + filePath?: string + description?: string + argumentHint?: string +} + +/** + * WebviewMessage + * Webview | CLI -> Extension + */ + +export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse" + +export type AudioType = "notification" | "celebration" | "progress_loop" + +export interface UpdateTodoListPayload { + todos: any[] +} + +export type EditQueuedMessagePayload = Pick + +export interface WebviewMessage { + type: + | "updateTodoList" + | "deleteMultipleTasksWithIds" + | "currentApiConfigName" + | "saveApiConfiguration" + | "upsertApiConfiguration" + | "deleteApiConfiguration" + | "loadApiConfiguration" + | "loadApiConfigurationById" + | "renameApiConfiguration" + | "getListApiConfiguration" + | "customInstructions" + | "webviewDidLaunch" + | "newTask" + | "askResponse" + | "terminalOperation" + | "clearTask" + | "didShowAnnouncement" + | "selectImages" + | "exportCurrentTask" + | "shareCurrentTask" + | "showTaskWithId" + | "deleteTaskWithId" + | "exportTaskWithId" + | "importSettings" + | "exportSettings" + | "resetState" + | "flushRouterModels" + | "requestRouterModels" + | "requestOpenAiModels" + | "requestOllamaModels" + | "requestLmStudioModels" + | "requestRooModels" + | "requestRooCreditBalance" + | "requestVsCodeLmModels" + | "requestHuggingFaceModels" + | "openImage" + | "saveImage" + | "openFile" + | "openMention" + | "cancelTask" + | "cancelAutoApproval" + | "updateVSCodeSetting" + | "getVSCodeSetting" + | "vsCodeSetting" + | "updateCondensingPrompt" + | "playSound" + | "playTts" + | "stopTts" + | "ttsEnabled" + | "ttsSpeed" + | "openKeyboardShortcuts" + | "openMcpSettings" + | "openProjectMcpSettings" + | "restartMcpServer" + | "refreshAllMcpServers" + | "toggleToolAlwaysAllow" + | "toggleToolEnabledForPrompt" + | "toggleMcpServer" + | "updateMcpTimeout" + | "enhancePrompt" + | "enhancedPrompt" + | "draggedImages" + | "deleteMessage" + | "deleteMessageConfirm" + | "submitEditedMessage" + | "editMessageConfirm" + | "enableMcpServerCreation" + | "remoteControlEnabled" + | "taskSyncEnabled" + | "searchCommits" + | "setApiConfigPassword" + | "mode" + | "updatePrompt" + | "getSystemPrompt" + | "copySystemPrompt" + | "systemPrompt" + | "enhancementApiConfigId" + | "autoApprovalEnabled" + | "updateCustomMode" + | "deleteCustomMode" + | "setopenAiCustomModelInfo" + | "openCustomModesSettings" + | "checkpointDiff" + | "checkpointRestore" + | "deleteMcpServer" + | "codebaseIndexEnabled" + | "telemetrySetting" + | "testBrowserConnection" + | "browserConnectionResult" + | "searchFiles" + | "toggleApiConfigPin" + | "hasOpenedModeSelector" + | "clearCloudAuthSkipModel" + | "cloudButtonClicked" + | "rooCloudSignIn" + | "cloudLandingPageSignIn" + | "rooCloudSignOut" + | "rooCloudManualUrl" + | "claudeCodeSignIn" + | "claudeCodeSignOut" + | "openAiCodexSignIn" + | "openAiCodexSignOut" + | "switchOrganization" + | "condenseTaskContextRequest" + | "requestIndexingStatus" + | "startIndexing" + | "clearIndexData" + | "indexingStatusUpdate" + | "indexCleared" + | "focusPanelRequest" + | "openExternal" + | "filterMarketplaceItems" + | "marketplaceButtonClicked" + | "installMarketplaceItem" + | "installMarketplaceItemWithParameters" + | "cancelMarketplaceInstall" + | "removeInstalledMarketplaceItem" + | "marketplaceInstallResult" + | "fetchMarketplaceData" + | "switchTab" + | "shareTaskSuccess" + | "exportMode" + | "exportModeResult" + | "importMode" + | "importModeResult" + | "checkRulesDirectory" + | "checkRulesDirectoryResult" + | "saveCodeIndexSettingsAtomic" + | "requestCodeIndexSecretStatus" + | "requestCommands" + | "openCommandFile" + | "deleteCommand" + | "createCommand" + | "insertTextIntoTextarea" + | "showMdmAuthRequiredNotification" + | "imageGenerationSettings" + | "queueMessage" + | "removeQueuedMessage" + | "editQueuedMessage" + | "dismissUpsell" + | "getDismissedUpsells" + | "updateSettings" + | "allowedCommands" + | "deniedCommands" + | "killBrowserSession" + | "openBrowserSessionPanel" + | "showBrowserSessionPanelAtStep" + | "refreshBrowserSessionPanel" + | "browserPanelDidLaunch" + | "openDebugApiHistory" + | "openDebugUiHistory" + | "downloadErrorDiagnostics" + | "requestClaudeCodeRateLimits" + | "refreshCustomTools" + | "requestModes" + | "switchMode" + | "debugSetting" + text?: string + editedMessageContent?: string + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" + disabled?: boolean + context?: string + dataUri?: string + askResponse?: ClineAskResponse + apiConfiguration?: ProviderSettings + images?: string[] + bool?: boolean + value?: number + stepIndex?: number + isLaunchAction?: boolean + forceShow?: boolean + commands?: string[] + audioType?: AudioType + serverName?: string + toolName?: string + alwaysAllow?: boolean + isEnabled?: boolean + mode?: string + promptMode?: string | "enhance" + customPrompt?: PromptComponent + dataUrls?: string[] + + values?: Record + query?: string + setting?: string + slug?: string + modeConfig?: ModeConfig + timeout?: number + payload?: WebViewMessagePayload + source?: "global" | "project" + requestId?: string + ids?: string[] + hasSystemPromptOverride?: boolean + terminalOperation?: "continue" | "abort" + messageTs?: number + restoreCheckpoint?: boolean + historyPreviewCollapsed?: boolean + filters?: { type?: string; search?: string; tags?: string[] } + + settings?: any + url?: string // For openExternal + mpItem?: MarketplaceItem + mpInstallOptions?: InstallMarketplaceItemOptions + + config?: Record // Add config to the payload + visibility?: ShareVisibility // For share visibility + hasContent?: boolean // For checkRulesDirectoryResult + checkOnly?: boolean // For deleteCustomMode check + upsellId?: string // For dismissUpsell + list?: string[] // For dismissedUpsells response + organizationId?: string | null // For organization switching + useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow + codeIndexSettings?: { + // Global state settings + codebaseIndexEnabled: boolean + codebaseIndexQdrantUrl: string + codebaseIndexEmbedderProvider: + | "openai" + | "ollama" + | "openai-compatible" + | "gemini" + | "mistral" + | "vercel-ai-gateway" + | "bedrock" + | "openrouter" + codebaseIndexEmbedderBaseUrl?: string + codebaseIndexEmbedderModelId: string + codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers + codebaseIndexOpenAiCompatibleBaseUrl?: string + codebaseIndexBedrockRegion?: string + codebaseIndexBedrockProfile?: string + codebaseIndexSearchMaxResults?: number + codebaseIndexSearchMinScore?: number + codebaseIndexOpenRouterSpecificProvider?: string // OpenRouter provider routing + + // Secret settings + codeIndexOpenAiKey?: string + codeIndexQdrantApiKey?: string + codebaseIndexOpenAiCompatibleApiKey?: string + codebaseIndexGeminiApiKey?: string + codebaseIndexMistralApiKey?: string + codebaseIndexVercelAiGatewayApiKey?: string + codebaseIndexOpenRouterApiKey?: string + } + updatedSettings?: RooCodeSettings +} + +export const checkoutDiffPayloadSchema = z.object({ + ts: z.number().optional(), + previousCommitHash: z.string().optional(), + commitHash: z.string(), + mode: z.enum(["full", "checkpoint", "from-init", "to-current"]), +}) + +export type CheckpointDiffPayload = z.infer + +export const checkoutRestorePayloadSchema = z.object({ + ts: z.number(), + commitHash: z.string(), + mode: z.enum(["preview", "restore"]), +}) + +export type CheckpointRestorePayload = z.infer + +export interface IndexingStatusPayload { + state: "Standby" | "Indexing" | "Indexed" | "Error" + message: string +} + +export interface IndexClearedPayload { + success: boolean + error?: string +} + +export const installMarketplaceItemWithParametersPayloadSchema = z.object({ + item: marketplaceItemSchema, + parameters: z.record(z.string(), z.any()), +}) + +export type InstallMarketplaceItemWithParametersPayload = z.infer< + typeof installMarketplaceItemWithParametersPayloadSchema +> + +export type WebViewMessagePayload = + | CheckpointDiffPayload + | CheckpointRestorePayload + | IndexingStatusPayload + | IndexClearedPayload + | InstallMarketplaceItemWithParametersPayload + | UpdateTodoListPayload + | EditQueuedMessagePayload + +export interface IndexingStatus { + systemStatus: string + message?: string + processedItems: number + totalItems: number + currentItemUnit?: string + workspacePath?: string +} + +export interface IndexingStatusUpdateMessage { + type: "indexingStatusUpdate" + values: IndexingStatus +} + +export interface LanguageModelChatSelector { + vendor?: string + family?: string + version?: string + id?: string +} + export interface ClineSayTool { tool: | "editedExistingFile" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index a468d1dd923..bc95852fad3 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -202,6 +202,8 @@ export interface WebviewMessage { | "rooCloudManualUrl" | "claudeCodeSignIn" | "claudeCodeSignOut" + | "openAiCodexSignIn" + | "openAiCodexSignOut" | "switchOrganization" | "condenseTaskContextRequest" | "requestIndexingStatus" diff --git a/webview-ui/src/components/kilocode/hooks/useProviderModels.ts b/webview-ui/src/components/kilocode/hooks/useProviderModels.ts index 9cf7a166bcd..9a8c095cfca 100644 --- a/webview-ui/src/components/kilocode/hooks/useProviderModels.ts +++ b/webview-ui/src/components/kilocode/hooks/useProviderModels.ts @@ -15,6 +15,8 @@ import { mistralModels, openAiNativeDefaultModelId, openAiNativeModels, + openAiCodexDefaultModelId, + openAiCodexModels, vertexDefaultModelId, vertexModels, xaiDefaultModelId, @@ -167,6 +169,14 @@ export const getModelsByProvider = ({ defaultModel: openAiNativeDefaultModelId, } } + + case "openai-codex": { + return { + models: openAiCodexModels, + defaultModel: openAiCodexDefaultModelId, + } + } + case "mistral": { return { models: mistralModels, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 2dd7e9c2df5..65386e07323 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -14,6 +14,7 @@ import { unboundDefaultModelId, litellmDefaultModelId, openAiNativeDefaultModelId, + openAiCodexDefaultModelId, anthropicDefaultModelId, doubaoDefaultModelId, claudeCodeDefaultModelId, @@ -96,6 +97,7 @@ import { Ollama, OpenAI, OpenAICompatible, + OpenAICodex, OpenRouter, QwenCode, Requesty, @@ -167,8 +169,13 @@ const ApiOptions = ({ currentApiConfigName, // kilocode_change }: ApiOptionsProps) => { const { t } = useAppTranslation() - const { organizationAllowList, kilocodeDefaultModel, cloudIsAuthenticated, claudeCodeIsAuthenticated } = - useExtensionState() + const { + organizationAllowList, + kilocodeDefaultModel, + cloudIsAuthenticated, + claudeCodeIsAuthenticated, + openAiCodexIsAuthenticated, + } = useExtensionState() const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -354,6 +361,7 @@ const ApiOptions = ({ // To address that we set the modelId to the default value for th // provider if it's not already set. const validateAndResetModel = ( + provider: ProviderName, modelId: string | undefined, field: keyof ProviderSettings, defaultValue?: string, @@ -361,11 +369,32 @@ const ApiOptions = ({ // in case we haven't set a default value for a provider if (!defaultValue) return - // only set default if no model is set, but don't reset invalid models - // let users see and decide what to do with invalid model selections - const shouldSetDefault = !modelId + // 1) If nothing is set, initialize to the provider default. + if (!modelId) { + setApiConfigurationField(field, defaultValue, false) + return + } + + // 2) If something *is* set, ensure it's valid for the newly selected provider. + // + // Without this, switching providers can leave the UI showing a model from the + // previously selected provider (including model IDs that don't exist for the + // newly selected provider). + // + // Note: We only validate providers with static model lists. + const staticModels = MODELS_BY_PROVIDER[provider] + if (!staticModels) { + return + } + + // Bedrock has a special “custom-arn” pseudo-model that isn't part of MODELS_BY_PROVIDER. + if (provider === "bedrock" && modelId === "custom-arn") { + return + } - if (shouldSetDefault) { + const filteredModels = filterModels(staticModels, provider, organizationAllowList) + const isValidModel = !!filteredModels && Object.prototype.hasOwnProperty.call(filteredModels, modelId) + if (!isValidModel) { setApiConfigurationField(field, defaultValue, false) } } @@ -390,6 +419,7 @@ const ApiOptions = ({ anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, cerebras: { field: "apiModelId", default: cerebrasDefaultModelId }, "claude-code": { field: "apiModelId", default: claudeCodeDefaultModelId }, + "openai-codex": { field: "apiModelId", default: openAiCodexDefaultModelId }, "qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId }, "openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, gemini: { field: "apiModelId", default: geminiDefaultModelId }, @@ -432,13 +462,14 @@ const ApiOptions = ({ const config = PROVIDER_MODEL_CONFIG[value] if (config) { validateAndResetModel( + value, apiConfiguration[config.field] as string | undefined, config.field, config.default, ) } }, - [setApiConfigurationField, apiConfiguration, kilocodeDefaultModel], + [setApiConfigurationField, apiConfiguration, organizationAllowList, kilocodeDefaultModel], ) const modelValidationError = useMemo(() => { @@ -657,6 +688,15 @@ const ApiOptions = ({ /> )} + {selectedProvider === "openai-codex" && ( + + )} + {selectedProvider === "openai-native" && ( 0 && selectedProvider !== "claude-code" && ( - <> -
- - -
- - {/* Show error if a deprecated model is selected */} - {selectedModelInfo?.deprecated && ( - - )} + {/* Skip generic model picker for claude-code/openai-codex since they have their own model pickers */} + {selectedProviderModels.length > 0 && + selectedProvider !== "claude-code" && + selectedProvider !== "openai-codex" && ( + <> +
+ + +
+ + {/* Show error if a deprecated model is selected */} + {selectedModelInfo?.deprecated && ( + + )} - {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( - - )} + {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( + + )} - {/* Only show model info if not deprecated */} - {!selectedModelInfo?.deprecated && ( - - )} - - )} + {/* Only show model info if not deprecated */} + {!selectedModelInfo?.deprecated && ( + + )} + + )} {!fromWelcomeView && ( { ;(MODELS_BY_PROVIDER as any).emptyProvider = {} // Add the empty provider to PROVIDERS - PROVIDERS.push({ value: "emptyProvider", label: "Empty Provider" }) + PROVIDERS.push({ value: "emptyProvider", label: "Empty Provider", proxy: false }) renderWithProviders() @@ -237,7 +237,7 @@ describe.skip("ApiOptions Provider Filtering", () => { // Add an empty static provider to test ;(MODELS_BY_PROVIDER as any).testEmptyProvider = {} // Add the provider to the PROVIDERS list - PROVIDERS.push({ value: "testEmptyProvider", label: "Test Empty Provider" }) + PROVIDERS.push({ value: "testEmptyProvider", label: "Test Empty Provider", proxy: false }) // Create a mock organization allow list that allows the provider but no models const allowList: OrganizationAllowList = { diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx index 31b7db38a55..507a872f745 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx @@ -4,6 +4,7 @@ import { render, screen, fireEvent } from "@/utils/test-utils" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { type ModelInfo, type ProviderSettings, openAiModelInfoSaneDefaults } from "@roo-code/types" +import { openAiCodexDefaultModelId } from "@roo-code/types" import * as ExtensionStateContext from "@src/context/ExtensionStateContext" const { ExtensionStateContextProvider } = ExtensionStateContext @@ -313,6 +314,31 @@ const renderApiOptions = (props: Partial = {}) => { } describe("ApiOptions", () => { + it("resets model to provider default when switching to openai-codex with an invalid prior apiModelId", () => { + const mockSetApiConfigurationField = vi.fn() + + renderApiOptions({ + apiConfiguration: { + apiProvider: "anthropic", + // Simulate a previously-selected model ID from another provider. + // When switching to OpenAI - ChatGPT Plus/Pro, this is invalid and should be reset. + apiModelId: "claude-3-5-sonnet-20241022", + }, + setApiConfigurationField: mockSetApiConfigurationField, + }) + + const providerSelectContainer = screen.getByTestId("provider-select") + const providerSelect = providerSelectContainer.querySelector("select") as HTMLSelectElement + expect(providerSelect).toBeInTheDocument() + + fireEvent.change(providerSelect, { target: { value: "openai-codex" } }) + + // Provider is updated + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("apiProvider", "openai-codex") + // Model is reset to the provider default since the previous value is invalid for this provider + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("apiModelId", openAiCodexDefaultModelId, false) + }) + it("shows diff settings, temperature and rate limit controls by default", () => { renderApiOptions({ apiConfiguration: { diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index a92f4f0e2c0..70d6e2d9f4c 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -13,6 +13,7 @@ import { // kilocode_change end mistralModels, openAiNativeModels, + openAiCodexModels, qwenCodeModels, vertexModels, xaiModels, @@ -41,6 +42,7 @@ export const MODELS_BY_PROVIDER: Partial a.label.localeCompare(b.label)) -PROVIDERS.unshift({ value: "kilocode", label: "Kilo Gateway" }) // kilocode_change +PROVIDERS.unshift({ value: "kilocode", label: "Kilo Gateway", proxy: false }) // kilocode_change diff --git a/webview-ui/src/components/settings/providers/OpenAICodex.tsx b/webview-ui/src/components/settings/providers/OpenAICodex.tsx new file mode 100644 index 00000000000..adb8c6a25c2 --- /dev/null +++ b/webview-ui/src/components/settings/providers/OpenAICodex.tsx @@ -0,0 +1,67 @@ +import React from "react" + +import { type ProviderSettings, openAiCodexDefaultModelId, openAiCodexModels } from "@roo-code/types" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Button } from "@src/components/ui" +import { vscode } from "@src/utils/vscode" + +import { ModelPicker } from "../ModelPicker" + +interface OpenAICodexProps { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void + simplifySettings?: boolean + openAiCodexIsAuthenticated?: boolean +} + +export const OpenAICodex: React.FC = ({ + apiConfiguration, + setApiConfigurationField, + simplifySettings, + openAiCodexIsAuthenticated = false, +}) => { + const { t } = useAppTranslation() + + return ( +
+ {/* Authentication Section */} +
+ {openAiCodexIsAuthenticated ? ( +
+ +
+ ) : ( + + )} +
+ + {/* Model Picker */} + +
+ ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 6af98e438fd..e7407ff5ed2 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -16,6 +16,7 @@ export { Moonshot } from "./Moonshot" export { NanoGpt } from "./NanoGpt" // kilocode_change export { Ollama } from "./Ollama" export { OpenAI } from "./OpenAI" +export { OpenAICodex } from "./OpenAICodex" export { OpenAICompatible } from "./OpenAICompatible" export { OpenRouter } from "./OpenRouter" export { QwenCode } from "./QwenCode" diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 2224d54f6b0..ed3f08b7b0c 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -28,6 +28,7 @@ import { openRouterDefaultModelId, claudeCodeModels, normalizeClaudeCodeModelId, + openAiCodexModels, sambaNovaModels, doubaoModels, internationalZAiModels, @@ -511,6 +512,11 @@ function getSelectedModel({ const info = qwenCodeModels[id as keyof typeof qwenCodeModels] return { id, info } } + case "openai-codex": { + const id = apiConfiguration.apiModelId ?? defaultModelId + const info = openAiCodexModels[id as keyof typeof openAiCodexModels] + return { id, info } + } case "vercel-ai-gateway": { const id = getValidatedModelId( apiConfiguration.vercelAiGatewayModelId,