@bitkyc08/opencodex 0.2.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +1 -1
- package/README.md +6 -3
- package/README.zh-CN.md +1 -1
- package/gui/dist/assets/{index-C9y3iMF1.js → index-CDhJ0DI7.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +3 -1
- package/src/abort.ts +29 -0
- package/src/adapters/anthropic.ts +15 -5
- package/src/adapters/google.ts +27 -11
- package/src/adapters/openai-chat.ts +38 -12
- package/src/adapters/openai-responses.ts +18 -1
- package/src/bridge.ts +155 -17
- package/src/cli.ts +38 -7
- package/src/codex-catalog.ts +130 -18
- package/src/codex-inject.ts +111 -12
- package/src/codex-paths.ts +59 -0
- package/src/config.ts +5 -0
- package/src/debug.ts +10 -0
- package/src/errors.ts +47 -0
- package/src/generated/jawcode-model-metadata.ts +69 -0
- package/src/init.ts +5 -32
- package/src/oauth/index.ts +19 -33
- package/src/oauth/key-providers.ts +2 -63
- package/src/providers/derive.ts +163 -0
- package/src/providers/registry.ts +140 -0
- package/src/responses/parser.ts +6 -1
- package/src/server.ts +182 -9
- package/src/service.ts +77 -14
- package/src/types.ts +6 -0
- package/src/vision/describe.ts +6 -1
- package/src/vision/index.ts +2 -1
- package/src/web-search/executor.ts +6 -1
- package/src/web-search/loop.ts +9 -3
- package/src/ws-bridge.ts +359 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { OcxProviderConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
export type ProviderAuthKind = "forward" | "oauth" | "key" | "local";
|
|
4
|
+
export type MetadataModelIdNormalize = "case-insensitive";
|
|
5
|
+
|
|
6
|
+
export interface ProviderRegistryEntry {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
adapter: string;
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
authKind: ProviderAuthKind;
|
|
12
|
+
featured?: boolean;
|
|
13
|
+
note?: string;
|
|
14
|
+
dashboardUrl?: string;
|
|
15
|
+
defaultModel?: string;
|
|
16
|
+
models?: string[];
|
|
17
|
+
noVisionModels?: string[];
|
|
18
|
+
noReasoningModels?: string[];
|
|
19
|
+
oauthId?: string;
|
|
20
|
+
jawcodeBundle?: string;
|
|
21
|
+
extraMetadataAliases?: string[];
|
|
22
|
+
metadataModelIdNormalize?: MetadataModelIdNormalize;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ProviderConfigSeed = Pick<
|
|
26
|
+
OcxProviderConfig,
|
|
27
|
+
"adapter" | "baseUrl" | "authMode" | "defaultModel" | "models" | "noVisionModels" | "noReasoningModels"
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
31
|
+
{
|
|
32
|
+
id: "openai",
|
|
33
|
+
label: "OpenAI (ChatGPT login)",
|
|
34
|
+
adapter: "openai-responses",
|
|
35
|
+
baseUrl: "https://chatgpt.com/backend-api/codex",
|
|
36
|
+
authKind: "forward",
|
|
37
|
+
featured: true,
|
|
38
|
+
note: "Uses your codex login — no API key",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "xai",
|
|
42
|
+
label: "xAI Grok",
|
|
43
|
+
adapter: "openai-chat",
|
|
44
|
+
baseUrl: "https://api.x.ai/v1",
|
|
45
|
+
authKind: "oauth",
|
|
46
|
+
featured: true,
|
|
47
|
+
oauthId: "xai",
|
|
48
|
+
jawcodeBundle: "xai",
|
|
49
|
+
note: "Log in with your Grok account",
|
|
50
|
+
models: ["grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
|
|
51
|
+
defaultModel: "grok-4.3",
|
|
52
|
+
noReasoningModels: ["grok-build-0.1", "grok-composer-2.5-fast"],
|
|
53
|
+
noVisionModels: ["grok-build-0.1", "grok-composer-2.5-fast"],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "anthropic",
|
|
57
|
+
label: "Anthropic Claude",
|
|
58
|
+
adapter: "anthropic",
|
|
59
|
+
baseUrl: "https://api.anthropic.com",
|
|
60
|
+
authKind: "oauth",
|
|
61
|
+
featured: true,
|
|
62
|
+
oauthId: "anthropic",
|
|
63
|
+
jawcodeBundle: "anthropic",
|
|
64
|
+
note: "Log in with your Claude account",
|
|
65
|
+
models: ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
|
|
66
|
+
defaultModel: "claude-sonnet-4-6",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "kimi",
|
|
70
|
+
label: "Kimi",
|
|
71
|
+
adapter: "openai-chat",
|
|
72
|
+
baseUrl: "https://api.kimi.com/coding/v1",
|
|
73
|
+
authKind: "oauth",
|
|
74
|
+
featured: true,
|
|
75
|
+
oauthId: "kimi",
|
|
76
|
+
jawcodeBundle: "moonshot",
|
|
77
|
+
note: "Log in with your Kimi account",
|
|
78
|
+
models: ["kimi-k2.6", "kimi-k2.5"],
|
|
79
|
+
defaultModel: "kimi-k2.6",
|
|
80
|
+
},
|
|
81
|
+
{ id: "openai-apikey", label: "OpenAI (API key)", adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authKind: "key", featured: true, dashboardUrl: "https://platform.openai.com/api-keys", defaultModel: "gpt-5.5" },
|
|
82
|
+
{ id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authKind: "key", featured: true, dashboardUrl: "https://opencode.ai/auth", defaultModel: "kimi-k2.6", jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…" },
|
|
83
|
+
{ id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter" },
|
|
84
|
+
{ id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" },
|
|
85
|
+
{ id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true, dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3-pro", jawcodeBundle: "google", extraMetadataAliases: ["gemini"] },
|
|
86
|
+
{ id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai/deployments/{deployment}", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
|
|
87
|
+
{ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
|
|
88
|
+
{ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
|
|
89
|
+
{ id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", featured: true, note: "Local — no key needed" },
|
|
90
|
+
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.deepseek.com/api_keys", models: ["deepseek-chat", "deepseek-reasoner"], defaultModel: "deepseek-chat" },
|
|
91
|
+
{ id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "llama-3.3-70b" },
|
|
92
|
+
{ id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" },
|
|
93
|
+
{ id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" },
|
|
94
|
+
{ id: "firepass", label: "Fire Pass (Fireworks Kimi)", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" },
|
|
95
|
+
{ id: "moonshot", label: "Moonshot (Kimi API)", baseUrl: "https://api.moonshot.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.moonshot.ai/console/api-keys", defaultModel: "kimi-k2-0905-preview", jawcodeBundle: "moonshot" },
|
|
96
|
+
{ id: "huggingface", label: "Hugging Face", baseUrl: "https://router.huggingface.co/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://huggingface.co/settings/tokens" },
|
|
97
|
+
{ id: "nvidia", label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://build.nvidia.com" },
|
|
98
|
+
{ id: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://venice.ai/settings/api" },
|
|
99
|
+
{ id: "zai", label: "Z.AI (GLM Coding)", baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://z.ai/manage-apikey/apikey-list", defaultModel: "glm-4.6" },
|
|
100
|
+
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" },
|
|
101
|
+
{ id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" },
|
|
102
|
+
{ id: "qwen-portal", label: "Qwen Portal", baseUrl: "https://portal.qwen.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://portal.qwen.ai" },
|
|
103
|
+
{ id: "qianfan", label: "Qianfan (Baidu)", baseUrl: "https://qianfan.baidubce.com/v2", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.bce.baidu.com/iam/#/iam/apikey/list" },
|
|
104
|
+
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://dashscope.console.aliyun.com/apiKey" },
|
|
105
|
+
{ id: "parallel", label: "Parallel", baseUrl: "https://platform.parallel.ai", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.parallel.ai" },
|
|
106
|
+
{ id: "zenmux", label: "ZenMux", baseUrl: "https://zenmux.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://zenmux.ai" },
|
|
107
|
+
{ id: "litellm", label: "LiteLLM (self-hosted)", baseUrl: "http://localhost:4000/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://docs.litellm.ai/docs/proxy/quick_start" },
|
|
108
|
+
{
|
|
109
|
+
id: "ollama-cloud",
|
|
110
|
+
label: "Ollama Cloud",
|
|
111
|
+
baseUrl: "https://ollama.com/v1",
|
|
112
|
+
adapter: "openai-chat",
|
|
113
|
+
authKind: "key",
|
|
114
|
+
dashboardUrl: "https://ollama.com/settings/keys",
|
|
115
|
+
models: ["glm-5.2", "deepseek-v4-pro", "qwen3-coder", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5", "gemma4"],
|
|
116
|
+
defaultModel: "glm-5.2",
|
|
117
|
+
noVisionModels: [
|
|
118
|
+
"glm-5.2", "glm-5.1", "glm-5", "glm-4.7",
|
|
119
|
+
"minimax-m2.7", "minimax-m2.5", "minimax-m2.1",
|
|
120
|
+
"nemotron-3-ultra", "nemotron-3-super",
|
|
121
|
+
"deepseek-v4-pro", "deepseek-v4-flash",
|
|
122
|
+
"gpt-oss", "qwen3-coder",
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
{ id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" },
|
|
126
|
+
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M2.5", jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive" },
|
|
127
|
+
{ id: "minimax-cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M2.5", jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive" },
|
|
128
|
+
{ id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.5" },
|
|
129
|
+
{ id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth" },
|
|
130
|
+
{ id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" },
|
|
131
|
+
{ id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" },
|
|
132
|
+
{ id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" },
|
|
133
|
+
{ id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
|
|
134
|
+
{ id: "github-copilot", label: "GitHub Copilot", baseUrl: "https://api.githubcopilot.com", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://github.com/settings/copilot" },
|
|
135
|
+
{ id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" },
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | undefined {
|
|
139
|
+
return PROVIDER_REGISTRY.find(entry => entry.id === id);
|
|
140
|
+
}
|
package/src/responses/parser.ts
CHANGED
|
@@ -386,7 +386,12 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
386
386
|
const structuredOutput = detectStructuredOutput(data.text);
|
|
387
387
|
|
|
388
388
|
return {
|
|
389
|
-
modelId: data.model,
|
|
389
|
+
modelId: data.model,
|
|
390
|
+
...(data.previous_response_id ? { previousResponseId: data.previous_response_id } : {}),
|
|
391
|
+
context,
|
|
392
|
+
stream: data.stream === true,
|
|
393
|
+
options,
|
|
394
|
+
_rawBody: body,
|
|
390
395
|
...(webSearch ? { _webSearch: webSearch } : {}),
|
|
391
396
|
...(structuredOutput ? { _structuredOutput: true } : {}),
|
|
392
397
|
};
|
package/src/server.ts
CHANGED
|
@@ -6,7 +6,17 @@ import { createGoogleAdapter } from "./adapters/google";
|
|
|
6
6
|
import { createOpenAIChatAdapter } from "./adapters/openai-chat";
|
|
7
7
|
import { createResponsesPassthroughAdapter } from "./adapters/openai-responses";
|
|
8
8
|
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "./bridge";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
buildWarmupCompletionFrames,
|
|
11
|
+
buildWsErrorFrame,
|
|
12
|
+
selectForwardHeaders,
|
|
13
|
+
sendJsonFrame,
|
|
14
|
+
sendResponseToWebSocket,
|
|
15
|
+
sendTextFrame,
|
|
16
|
+
type WsData,
|
|
17
|
+
} from "./ws-bridge";
|
|
18
|
+
import type { ServerWebSocket } from "bun";
|
|
19
|
+
import { DEFAULT_SUBAGENT_MODELS, loadConfig, saveConfig, websocketsEnabled } from "./config";
|
|
10
20
|
import { parseRequest } from "./responses/parser";
|
|
11
21
|
import { routeModel } from "./router";
|
|
12
22
|
import { namespacedToolName } from "./types";
|
|
@@ -19,6 +29,7 @@ import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "./web-searc
|
|
|
19
29
|
import { describeImagesInPlace, planVisionSidecar } from "./vision";
|
|
20
30
|
import { removeCredential } from "./oauth/store";
|
|
21
31
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "./oauth/key-providers";
|
|
32
|
+
import { deriveProviderPresets } from "./providers/derive";
|
|
22
33
|
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
23
34
|
|
|
24
35
|
const VERSION = "0.0.1";
|
|
@@ -67,7 +78,7 @@ function serveGuiFile(pathname: string): Response | null {
|
|
|
67
78
|
});
|
|
68
79
|
}
|
|
69
80
|
|
|
70
|
-
function resolveAdapter(providerConfig: OcxProviderConfig) {
|
|
81
|
+
export function resolveAdapter(providerConfig: OcxProviderConfig) {
|
|
71
82
|
switch (providerConfig.adapter) {
|
|
72
83
|
case "openai-chat":
|
|
73
84
|
return createOpenAIChatAdapter(providerConfig);
|
|
@@ -77,6 +88,7 @@ function resolveAdapter(providerConfig: OcxProviderConfig) {
|
|
|
77
88
|
return createResponsesPassthroughAdapter(providerConfig);
|
|
78
89
|
case "google":
|
|
79
90
|
return createGoogleAdapter(providerConfig);
|
|
91
|
+
case "azure":
|
|
80
92
|
case "azure-openai":
|
|
81
93
|
return createAzureAdapter(providerConfig);
|
|
82
94
|
default:
|
|
@@ -84,7 +96,12 @@ function resolveAdapter(providerConfig: OcxProviderConfig) {
|
|
|
84
96
|
}
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
async function handleResponses(
|
|
99
|
+
async function handleResponses(
|
|
100
|
+
req: Request,
|
|
101
|
+
config: OcxConfig,
|
|
102
|
+
logCtx: { model: string; provider: string },
|
|
103
|
+
options: { forceEmptyResponseId?: boolean; abortSignal?: AbortSignal } = {},
|
|
104
|
+
): Promise<Response> {
|
|
88
105
|
let body: unknown;
|
|
89
106
|
try {
|
|
90
107
|
body = await req.json();
|
|
@@ -133,24 +150,30 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
|
|
|
133
150
|
// with text BEFORE the main call, so the text-only model can reason about it.
|
|
134
151
|
const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, req.headers);
|
|
135
152
|
if (visionPlan) {
|
|
136
|
-
await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings);
|
|
153
|
+
await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings, options.abortSignal);
|
|
137
154
|
}
|
|
138
155
|
|
|
139
156
|
const adapter = resolveAdapter(route.provider);
|
|
140
157
|
|
|
141
158
|
if ("passthrough" in adapter && adapter.passthrough) {
|
|
142
159
|
const request = adapter.buildRequest(parsed, { headers: req.headers });
|
|
160
|
+
// Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
|
|
161
|
+
// consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
|
|
162
|
+
// whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
|
|
163
|
+
const upstream = new AbortController();
|
|
164
|
+
linkAbortSignal(upstream, options.abortSignal);
|
|
143
165
|
let upstreamResponse: Response;
|
|
144
166
|
try {
|
|
145
167
|
upstreamResponse = await fetch(request.url, {
|
|
146
168
|
method: request.method,
|
|
147
169
|
headers: request.headers,
|
|
148
170
|
body: request.body,
|
|
171
|
+
signal: upstream.signal,
|
|
149
172
|
});
|
|
150
173
|
} catch (err) {
|
|
151
174
|
return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
|
152
175
|
}
|
|
153
|
-
return new Response(upstreamResponse.body, {
|
|
176
|
+
return new Response(relayWithAbort(upstreamResponse.body, upstream), {
|
|
154
177
|
status: upstreamResponse.status,
|
|
155
178
|
headers: sanitizePassthroughHeaders(upstreamResponse.headers),
|
|
156
179
|
});
|
|
@@ -169,17 +192,23 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
|
|
|
169
192
|
incomingHeaders: req.headers,
|
|
170
193
|
settings: wsPlan.settings,
|
|
171
194
|
maxSearches: wsPlan.maxSearches,
|
|
195
|
+
abortSignal: options.abortSignal,
|
|
172
196
|
});
|
|
173
197
|
}
|
|
174
198
|
|
|
175
199
|
const request = adapter.buildRequest(parsed, { headers: req.headers });
|
|
176
200
|
|
|
201
|
+
// Abort the upstream fetch if the client (Codex) disconnects mid-stream, so a cancelled turn does
|
|
202
|
+
// not leak the upstream connection or keep draining tokens. The bridge's cancel() fires upstream.abort() (RC2).
|
|
203
|
+
const upstream = new AbortController();
|
|
204
|
+
linkAbortSignal(upstream, options.abortSignal);
|
|
177
205
|
let upstreamResponse: Response;
|
|
178
206
|
try {
|
|
179
207
|
upstreamResponse = await fetch(request.url, {
|
|
180
208
|
method: request.method,
|
|
181
209
|
headers: request.headers,
|
|
182
210
|
body: request.body,
|
|
211
|
+
signal: upstream.signal,
|
|
183
212
|
});
|
|
184
213
|
} catch (err) {
|
|
185
214
|
return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -202,7 +231,16 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
|
|
|
202
231
|
if (t.freeform) freeformToolNames.add(t.name);
|
|
203
232
|
if (t.toolSearch) toolSearchToolNames.add(t.name);
|
|
204
233
|
}
|
|
205
|
-
const sseStream = bridgeToResponsesSSE(
|
|
234
|
+
const sseStream = bridgeToResponsesSSE(
|
|
235
|
+
eventStream,
|
|
236
|
+
parsed.modelId,
|
|
237
|
+
toolNsMap,
|
|
238
|
+
freeformToolNames,
|
|
239
|
+
toolSearchToolNames,
|
|
240
|
+
() => upstream.abort(),
|
|
241
|
+
2_000,
|
|
242
|
+
options.forceEmptyResponseId ? { responseId: "" } : undefined,
|
|
243
|
+
);
|
|
206
244
|
return new Response(sseStream, {
|
|
207
245
|
headers: {
|
|
208
246
|
"Content-Type": "text/event-stream",
|
|
@@ -224,6 +262,15 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
|
|
|
224
262
|
return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");
|
|
225
263
|
}
|
|
226
264
|
|
|
265
|
+
export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): void {
|
|
266
|
+
if (!signal) return;
|
|
267
|
+
if (signal.aborted) {
|
|
268
|
+
upstream.abort(signal.reason);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
signal.addEventListener("abort", () => upstream.abort(signal.reason), { once: true });
|
|
272
|
+
}
|
|
273
|
+
|
|
227
274
|
const requestLog: { timestamp: number; model: string; provider: string; status: number; durationMs: number }[] = [];
|
|
228
275
|
const MAX_LOG_SIZE = 200;
|
|
229
276
|
|
|
@@ -232,6 +279,39 @@ function addRequestLog(entry: typeof requestLog[number]) {
|
|
|
232
279
|
if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
|
|
233
280
|
}
|
|
234
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Relay an upstream body verbatim while wiring client-cancel -> upstream.abort(). A body returned
|
|
284
|
+
* directly from fetch does NOT propagate the consumer's cancel to a signalled fetch, so a client
|
|
285
|
+
* disconnect would leak the upstream connection. Pumping through this stream (whose cancel() aborts
|
|
286
|
+
* the upstream) fixes the leak with zero byte changes — passthrough fidelity is preserved (RC2).
|
|
287
|
+
*/
|
|
288
|
+
export function relayWithAbort(
|
|
289
|
+
body: ReadableStream<Uint8Array> | null,
|
|
290
|
+
upstream: AbortController,
|
|
291
|
+
): ReadableStream<Uint8Array> | null {
|
|
292
|
+
if (!body) return null;
|
|
293
|
+
const reader = body.getReader();
|
|
294
|
+
return new ReadableStream<Uint8Array>({
|
|
295
|
+
async pull(controller) {
|
|
296
|
+
try {
|
|
297
|
+
const { done, value } = await reader.read();
|
|
298
|
+
if (done) {
|
|
299
|
+
controller.close();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
controller.enqueue(value);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
try { controller.error(err); } catch { /* already torn down */ }
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
cancel(reason) {
|
|
308
|
+
// Client disconnected: abort the upstream fetch and release the reader so we do not leak it.
|
|
309
|
+
upstream.abort(reason);
|
|
310
|
+
reader.cancel(reason).catch(() => {});
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
235
315
|
/**
|
|
236
316
|
* Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
|
|
237
317
|
* (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
|
|
@@ -239,7 +319,18 @@ function addRequestLog(entry: typeof requestLog[number]) {
|
|
|
239
319
|
* Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim.
|
|
240
320
|
*/
|
|
241
321
|
export function sanitizePassthroughHeaders(upstream: Headers): Headers {
|
|
242
|
-
const DROP = new Set([
|
|
322
|
+
const DROP = new Set([
|
|
323
|
+
"content-encoding",
|
|
324
|
+
"content-length",
|
|
325
|
+
"transfer-encoding",
|
|
326
|
+
"connection",
|
|
327
|
+
"keep-alive",
|
|
328
|
+
"proxy-authenticate",
|
|
329
|
+
"proxy-authorization",
|
|
330
|
+
"te",
|
|
331
|
+
"trailer",
|
|
332
|
+
"upgrade",
|
|
333
|
+
]);
|
|
243
334
|
const out = new Headers();
|
|
244
335
|
upstream.forEach((value, key) => {
|
|
245
336
|
if (!DROP.has(key.toLowerCase())) out.set(key, value);
|
|
@@ -361,6 +452,12 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
|
|
|
361
452
|
return jsonResponse({ providers: listKeyLoginProviders() });
|
|
362
453
|
}
|
|
363
454
|
|
|
455
|
+
// Complete GUI picker presets, derived from the canonical provider registry. The GUI is a
|
|
456
|
+
// standalone Vite package, so it consumes this runtime view instead of importing repo-root src.
|
|
457
|
+
if (url.pathname === "/api/provider-presets" && req.method === "GET") {
|
|
458
|
+
return jsonResponse({ providers: deriveProviderPresets() });
|
|
459
|
+
}
|
|
460
|
+
|
|
364
461
|
// Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
|
|
365
462
|
// first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
|
|
366
463
|
if (url.pathname === "/api/subagent-models" && req.method === "GET") {
|
|
@@ -452,7 +549,7 @@ export function startServer(port?: number) {
|
|
|
452
549
|
}
|
|
453
550
|
const listenPort = port ?? config.port ?? 10100;
|
|
454
551
|
|
|
455
|
-
const server = Bun.serve({
|
|
552
|
+
const server = Bun.serve<WsData>({
|
|
456
553
|
port: listenPort,
|
|
457
554
|
async fetch(req) {
|
|
458
555
|
const url = new URL(req.url);
|
|
@@ -461,6 +558,13 @@ export function startServer(port?: number) {
|
|
|
461
558
|
return new Response(null, { status: 204, headers: corsHeaders() });
|
|
462
559
|
}
|
|
463
560
|
|
|
561
|
+
// Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
|
|
562
|
+
// handshake-time only, so capture inbound headers and thread them into the pipeline.
|
|
563
|
+
if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
564
|
+
if (server.upgrade(req, { data: { headers: selectForwardHeaders(req.headers) } })) return undefined as unknown as Response;
|
|
565
|
+
return formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed");
|
|
566
|
+
}
|
|
567
|
+
|
|
464
568
|
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
465
569
|
return jsonResponse({ status: "ok", version: VERSION, uptime: process.uptime() });
|
|
466
570
|
}
|
|
@@ -481,7 +585,7 @@ export function startServer(port?: number) {
|
|
|
481
585
|
// Codex client → Codex catalog shape: native gpt + namespaced routed models,
|
|
482
586
|
// cloned from a native template so required fields (base_instructions, etc.) are present.
|
|
483
587
|
// Pass the subagent picks so featured models lead by priority (matches the on-disk file).
|
|
484
|
-
return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels) });
|
|
588
|
+
return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config)) });
|
|
485
589
|
}
|
|
486
590
|
// OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
|
|
487
591
|
const data = [
|
|
@@ -510,6 +614,75 @@ export function startServer(port?: number) {
|
|
|
510
614
|
|
|
511
615
|
return formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`);
|
|
512
616
|
},
|
|
617
|
+
websocket: {
|
|
618
|
+
// Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
|
|
619
|
+
// socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
|
|
620
|
+
// Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
|
|
621
|
+
message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
622
|
+
let frame: Record<string, unknown>;
|
|
623
|
+
try {
|
|
624
|
+
frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record<string, unknown>;
|
|
625
|
+
} catch {
|
|
626
|
+
return; // text-only contract; ignore unparseable frames
|
|
627
|
+
}
|
|
628
|
+
if (frame.type === "response.processed") return; // ack — no-op
|
|
629
|
+
if (frame.type !== "response.create") return;
|
|
630
|
+
|
|
631
|
+
ws.data.cancel?.();
|
|
632
|
+
const turnId = (ws.data.turnId ?? 0) + 1;
|
|
633
|
+
ws.data.turnId = turnId;
|
|
634
|
+
const isCurrent = () => ws.data.turnId === turnId;
|
|
635
|
+
const turnAbort = new AbortController();
|
|
636
|
+
const cancelTurn = () => {
|
|
637
|
+
turnAbort.abort("websocket turn superseded or closed");
|
|
638
|
+
};
|
|
639
|
+
ws.data.cancel = cancelTurn;
|
|
640
|
+
|
|
641
|
+
if (frame.generate === false) {
|
|
642
|
+
for (const payload of buildWarmupCompletionFrames(frame)) {
|
|
643
|
+
if (!isCurrent()) return;
|
|
644
|
+
sendTextFrame(ws, payload);
|
|
645
|
+
}
|
|
646
|
+
if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const payload: Record<string, unknown> = { ...frame };
|
|
651
|
+
delete payload.type;
|
|
652
|
+
void (async () => {
|
|
653
|
+
const logCtx = { model: "unknown", provider: "unknown" };
|
|
654
|
+
const fwd = new Headers({ "content-type": "application/json" });
|
|
655
|
+
ws.data.headers?.forEach((value, key) => fwd.set(key, value));
|
|
656
|
+
const req = new Request("http://localhost/v1/responses", {
|
|
657
|
+
method: "POST",
|
|
658
|
+
headers: fwd,
|
|
659
|
+
body: JSON.stringify({ ...payload, stream: true }),
|
|
660
|
+
});
|
|
661
|
+
try {
|
|
662
|
+
const response = await handleResponses(req, config, logCtx, {
|
|
663
|
+
forceEmptyResponseId: true,
|
|
664
|
+
abortSignal: turnAbort.signal,
|
|
665
|
+
});
|
|
666
|
+
await sendResponseToWebSocket(ws, response, isCurrent);
|
|
667
|
+
} catch (err) {
|
|
668
|
+
if (!isCurrent()) return;
|
|
669
|
+
try {
|
|
670
|
+
sendJsonFrame(ws, buildWsErrorFrame(502, {
|
|
671
|
+
type: "proxy_error",
|
|
672
|
+
message: err instanceof Error ? err.message : String(err),
|
|
673
|
+
}));
|
|
674
|
+
} catch {
|
|
675
|
+
/* socket already gone or send dropped */
|
|
676
|
+
}
|
|
677
|
+
} finally {
|
|
678
|
+
if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
|
|
679
|
+
}
|
|
680
|
+
})();
|
|
681
|
+
},
|
|
682
|
+
close(ws: ServerWebSocket<WsData>) {
|
|
683
|
+
ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
|
|
684
|
+
},
|
|
685
|
+
},
|
|
513
686
|
});
|
|
514
687
|
|
|
515
688
|
console.log(`🚀 opencodex proxy running on http://localhost:${listenPort}`);
|
package/src/service.ts
CHANGED
|
@@ -28,10 +28,25 @@ function logPath(): string {
|
|
|
28
28
|
return join(getConfigDir(), "service.log");
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function windowsServiceScriptPath(): string {
|
|
32
|
+
return join(getConfigDir(), "opencodex-service.cmd");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function plistString(value: string): string {
|
|
36
|
+
return value
|
|
37
|
+
.replace(/&/g, "&")
|
|
38
|
+
.replace(/</g, "<")
|
|
39
|
+
.replace(/>/g, ">")
|
|
40
|
+
.replace(/"/g, """)
|
|
41
|
+
.replace(/'/g, "'");
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
export function buildPlist(): string {
|
|
32
45
|
const { bun, cli } = cliEntry();
|
|
33
46
|
const log = logPath();
|
|
34
47
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
48
|
+
const codexHome = process.env.CODEX_HOME?.trim();
|
|
49
|
+
const codexHomeXml = codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : "";
|
|
35
50
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
36
51
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
37
52
|
<plist version="1.0">
|
|
@@ -39,8 +54,8 @@ export function buildPlist(): string {
|
|
|
39
54
|
<key>Label</key><string>${LABEL}</string>
|
|
40
55
|
<key>ProgramArguments</key>
|
|
41
56
|
<array>
|
|
42
|
-
<string>${bun}</string>
|
|
43
|
-
<string>${cli}</string>
|
|
57
|
+
<string>${plistString(bun)}</string>
|
|
58
|
+
<string>${plistString(cli)}</string>
|
|
44
59
|
<string>start</string>
|
|
45
60
|
</array>
|
|
46
61
|
<key>RunAtLoad</key><true/>
|
|
@@ -48,19 +63,57 @@ export function buildPlist(): string {
|
|
|
48
63
|
<key>EnvironmentVariables</key>
|
|
49
64
|
<dict>
|
|
50
65
|
<key>OCX_SERVICE</key><string>1</string>
|
|
51
|
-
<key>PATH</key><string>${path}</string>
|
|
52
|
-
</dict>
|
|
53
|
-
<key>StandardOutPath</key><string>${log}</string>
|
|
54
|
-
<key>StandardErrorPath</key><string>${log}</string>
|
|
66
|
+
<key>PATH</key><string>${plistString(path)}</string>
|
|
67
|
+
${codexHomeXml ? `${codexHomeXml}\n` : ""} </dict>
|
|
68
|
+
<key>StandardOutPath</key><string>${plistString(log)}</string>
|
|
69
|
+
<key>StandardErrorPath</key><string>${plistString(log)}</string>
|
|
55
70
|
</dict>
|
|
56
71
|
</plist>
|
|
57
72
|
`;
|
|
58
73
|
}
|
|
59
74
|
|
|
75
|
+
function systemdQuote(value: string): string {
|
|
76
|
+
return `"${value
|
|
77
|
+
.replace(/\\/g, "\\\\")
|
|
78
|
+
.replace(/"/g, "\\\"")
|
|
79
|
+
.replace(/%/g, "%%")
|
|
80
|
+
.replace(/\n/g, "\\n")}"`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function systemdEnvironmentAssignment(name: string, value: string | undefined): string | null {
|
|
84
|
+
if (!value) return null;
|
|
85
|
+
return `Environment=${systemdQuote(`${name}=${value}`)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
60
88
|
function sh(cmd: string): string {
|
|
61
89
|
return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
62
90
|
}
|
|
63
91
|
|
|
92
|
+
function windowsBatchValue(value: string): string {
|
|
93
|
+
return value.replace(/%/g, "%%").replace(/[\r\n]/g, "");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function windowsBatchSet(name: string, value: string | undefined): string | null {
|
|
97
|
+
if (!value) return null;
|
|
98
|
+
return `set "${name}=${windowsBatchValue(value)}"`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildWindowsServiceScript(): string {
|
|
102
|
+
const { bun, cli } = cliEntry();
|
|
103
|
+
const path = process.env.PATH ?? "";
|
|
104
|
+
const lines = [
|
|
105
|
+
"@echo off",
|
|
106
|
+
"setlocal",
|
|
107
|
+
windowsBatchSet("OCX_SERVICE", "1"),
|
|
108
|
+
windowsBatchSet("PATH", path),
|
|
109
|
+
windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim()),
|
|
110
|
+
`"${bun}" "${cli}" start`,
|
|
111
|
+
"set \"OCX_EXIT=%ERRORLEVEL%\"",
|
|
112
|
+
"endlocal & exit /b %OCX_EXIT%",
|
|
113
|
+
].filter((line): line is string => Boolean(line));
|
|
114
|
+
return `${lines.join("\r\n")}\r\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
64
117
|
// ── macOS (launchd) ──
|
|
65
118
|
function installLaunchd(): void {
|
|
66
119
|
const dir = join(homedir(), "Library", "LaunchAgents");
|
|
@@ -82,14 +135,19 @@ function uninstallLaunchd(): void {
|
|
|
82
135
|
|
|
83
136
|
// ── Windows (Task Scheduler) ──
|
|
84
137
|
function installWindows(): void {
|
|
85
|
-
|
|
86
|
-
|
|
138
|
+
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
139
|
+
const script = windowsServiceScriptPath();
|
|
140
|
+
writeFileSync(script, buildWindowsServiceScript(), "utf8");
|
|
141
|
+
sh(`schtasks /create /tn ${TASK} /tr "\\"${script}\\"" /sc onlogon /rl highest /f`);
|
|
87
142
|
sh(`schtasks /run /tn ${TASK}`);
|
|
88
143
|
}
|
|
89
144
|
function startWindows(): void { sh(`schtasks /run /tn ${TASK}`); }
|
|
90
145
|
function stopWindows(): void { try { sh(`schtasks /end /tn ${TASK}`); } catch { /* not running */ } }
|
|
91
146
|
function statusWindows(): string { try { return sh(`schtasks /query /tn ${TASK}`); } catch { return ""; } }
|
|
92
|
-
function uninstallWindows(): void {
|
|
147
|
+
function uninstallWindows(): void {
|
|
148
|
+
try { sh(`schtasks /delete /tn ${TASK} /f`); } catch { /* absent */ }
|
|
149
|
+
if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
|
|
150
|
+
}
|
|
93
151
|
|
|
94
152
|
// ── Linux (systemd user unit) ──
|
|
95
153
|
function unitDir(): string {
|
|
@@ -104,6 +162,12 @@ export function buildUnit(): string {
|
|
|
104
162
|
const { bun, cli } = cliEntry();
|
|
105
163
|
const log = logPath();
|
|
106
164
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
165
|
+
const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
|
|
166
|
+
const envLines = [
|
|
167
|
+
systemdEnvironmentAssignment("OCX_SERVICE", "1"),
|
|
168
|
+
systemdEnvironmentAssignment("PATH", path),
|
|
169
|
+
codexHome,
|
|
170
|
+
].filter((line): line is string => Boolean(line)).join("\n");
|
|
107
171
|
return `[Unit]
|
|
108
172
|
Description=OpenCodex Proxy Server
|
|
109
173
|
After=network-online.target
|
|
@@ -111,13 +175,12 @@ Wants=network-online.target
|
|
|
111
175
|
|
|
112
176
|
[Service]
|
|
113
177
|
Type=simple
|
|
114
|
-
ExecStart=${bun} ${cli} start
|
|
178
|
+
ExecStart=${systemdQuote(bun)} ${systemdQuote(cli)} start
|
|
115
179
|
Restart=on-failure
|
|
116
180
|
RestartSec=5
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
StandardError=append:${log}
|
|
181
|
+
${envLines}
|
|
182
|
+
StandardOutput=${systemdQuote(`append:${log}`)}
|
|
183
|
+
StandardError=${systemdQuote(`append:${log}`)}
|
|
121
184
|
|
|
122
185
|
[Install]
|
|
123
186
|
WantedBy=default.target
|