clauderipple 0.2.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/CHANGELOG.md +229 -0
- package/LICENSE +674 -0
- package/README.ko.md +328 -0
- package/README.md +372 -0
- package/bin/clauderipple.js +12 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/main.js +518 -0
- package/dist/cli/src/browser.js +21 -0
- package/dist/cli/src/bundle.js +51 -0
- package/dist/cli/src/certs.js +33 -0
- package/dist/cli/src/claude-auth.js +112 -0
- package/dist/cli/src/codex.js +172 -0
- package/dist/cli/src/gen-certs.js +7 -0
- package/dist/cli/src/hooks/agent-title.js +160 -0
- package/dist/cli/src/index.js +489 -0
- package/dist/cli/src/launchd.js +183 -0
- package/dist/cli/src/picker.js +166 -0
- package/dist/cli/src/probe.js +55 -0
- package/dist/cli/src/runtime.js +62 -0
- package/dist/cli/src/schtasks.js +134 -0
- package/dist/cli/src/settings.js +142 -0
- package/dist/cli/src/supervisor.js +100 -0
- package/dist/cli/src/tray.js +85 -0
- package/dist/router/src/admin.js +945 -0
- package/dist/router/src/bootstrap.js +80 -0
- package/dist/router/src/certs.js +65 -0
- package/dist/router/src/compat.js +172 -0
- package/dist/router/src/config.js +179 -0
- package/dist/router/src/health.js +45 -0
- package/dist/router/src/identity.js +51 -0
- package/dist/router/src/index.js +144 -0
- package/dist/router/src/ingress/models.js +29 -0
- package/dist/router/src/ingress/server.js +400 -0
- package/dist/router/src/ingress/translate.js +457 -0
- package/dist/router/src/log.js +81 -0
- package/dist/router/src/picker.js +74 -0
- package/dist/router/src/presets.js +267 -0
- package/dist/router/src/providers/anthropic-observed.js +88 -0
- package/dist/router/src/providers/anthropic-token-file.js +48 -0
- package/dist/router/src/providers/anthropic.js +203 -0
- package/dist/router/src/providers/chatgpt/auth.js +226 -0
- package/dist/router/src/providers/chatgpt/index.js +274 -0
- package/dist/router/src/providers/chatgpt/sse.js +28 -0
- package/dist/router/src/providers/chatgpt/translate.js +393 -0
- package/dist/router/src/providers/claude-oauth.js +252 -0
- package/dist/router/src/providers/openai/index.js +193 -0
- package/dist/router/src/providers/openai/translate.js +504 -0
- package/dist/router/src/proxy.js +724 -0
- package/dist/router/src/redact.js +43 -0
- package/dist/router/src/requestlog.js +346 -0
- package/dist/router/src/routing.js +113 -0
- package/dist/router/src/version.js +8 -0
- package/dist/router/src/x509.js +203 -0
- package/dist/ui/app.js +1228 -0
- package/dist/ui/i18n.js +95 -0
- package/dist/ui/index.html +104 -0
- package/dist/ui/presets-fallback.js +61 -0
- package/dist/ui/style.css +347 -0
- package/docs/ARCHITECTURE.md +441 -0
- package/package.json +66 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Curated providers with a documented native Anthropic Messages endpoint.
|
|
2
|
+
// Keep this catalog conservative: providers with only an OpenAI-compatible API belong
|
|
3
|
+
// behind a translation adapter, not in this host-rewrite-only catalog.
|
|
4
|
+
export const PRESETS = [
|
|
5
|
+
// Docs: https://api-docs.deepseek.com/guides/anthropic_api/
|
|
6
|
+
{
|
|
7
|
+
id: "deepseek",
|
|
8
|
+
kind: "anthropic-compatible",
|
|
9
|
+
name: "DeepSeek",
|
|
10
|
+
vendorUrl: "https://www.deepseek.com/",
|
|
11
|
+
anthropicBaseUrl: "https://api.deepseek.com/anthropic",
|
|
12
|
+
authHeader: "x-api-key",
|
|
13
|
+
fallbackModels: [
|
|
14
|
+
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
|
15
|
+
{ id: "deepseek-flash", name: "DeepSeek Flash" },
|
|
16
|
+
],
|
|
17
|
+
// https://api-docs.deepseek.com/guides/anthropic_api/ documents thinking and output_config.effort,
|
|
18
|
+
// but no accepted effort-level set for its Anthropic endpoint; strip effort rather than guess.
|
|
19
|
+
effortLevels: [],
|
|
20
|
+
thinking: "enabled",
|
|
21
|
+
verified: true,
|
|
22
|
+
notes: "Anthropic thinking is accepted; budget_tokens is ignored.",
|
|
23
|
+
docsUrl: "https://api-docs.deepseek.com/guides/anthropic_api/",
|
|
24
|
+
},
|
|
25
|
+
// Docs: https://platform.kimi.ai/docs/api/overview
|
|
26
|
+
{
|
|
27
|
+
id: "kimi",
|
|
28
|
+
kind: "anthropic-compatible",
|
|
29
|
+
name: "Kimi (Moonshot AI)",
|
|
30
|
+
vendorUrl: "https://platform.kimi.ai/",
|
|
31
|
+
anthropicBaseUrl: "https://api.moonshot.ai/anthropic",
|
|
32
|
+
authHeader: "authorization-bearer",
|
|
33
|
+
modelsUrl: "https://api.moonshot.ai/v1/models",
|
|
34
|
+
modelsAuthHeader: "authorization-bearer",
|
|
35
|
+
fallbackModels: [
|
|
36
|
+
{ id: "kimi-k3", name: "Kimi K3" },
|
|
37
|
+
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
|
|
38
|
+
],
|
|
39
|
+
// https://platform.kimi.ai/docs/api/overview documents the endpoint but no Anthropic effort/thinking contract.
|
|
40
|
+
effortLevels: [],
|
|
41
|
+
thinking: "none",
|
|
42
|
+
verified: true,
|
|
43
|
+
notes: "K3 exists and is documented alongside K2.7 Code for Claude Code.",
|
|
44
|
+
docsUrl: "https://platform.kimi.ai/docs/api/overview",
|
|
45
|
+
},
|
|
46
|
+
// Docs: https://docs.z.ai/devpack/tool/others
|
|
47
|
+
{
|
|
48
|
+
id: "zai",
|
|
49
|
+
kind: "anthropic-compatible",
|
|
50
|
+
name: "Z.AI (GLM)",
|
|
51
|
+
vendorUrl: "https://z.ai/",
|
|
52
|
+
anthropicBaseUrl: "https://api.z.ai/api/anthropic",
|
|
53
|
+
authHeader: "authorization-bearer",
|
|
54
|
+
fallbackModels: [{ id: "glm-5.2", name: "GLM-5.2" }],
|
|
55
|
+
// https://docs.z.ai/devpack/tool/others documents the endpoint but not an Anthropic effort/thinking contract.
|
|
56
|
+
effortLevels: [],
|
|
57
|
+
thinking: "none",
|
|
58
|
+
verified: false,
|
|
59
|
+
notes: "The Anthropic endpoint is documented; model discovery and an effort field were not verified in the Anthropic-protocol documentation.",
|
|
60
|
+
docsUrl: "https://docs.z.ai/devpack/tool/others",
|
|
61
|
+
},
|
|
62
|
+
// Docs: https://platform.minimax.io/docs/api-reference/text-anthropic-api
|
|
63
|
+
{
|
|
64
|
+
id: "minimax",
|
|
65
|
+
kind: "anthropic-compatible",
|
|
66
|
+
name: "MiniMax",
|
|
67
|
+
vendorUrl: "https://www.minimax.io/",
|
|
68
|
+
anthropicBaseUrl: "https://api.minimax.io/anthropic",
|
|
69
|
+
authHeader: "x-api-key",
|
|
70
|
+
fallbackModels: [
|
|
71
|
+
{ id: "MiniMax-M3", name: "MiniMax M3" },
|
|
72
|
+
{ id: "MiniMax-M2.7", name: "MiniMax M2.7" },
|
|
73
|
+
],
|
|
74
|
+
// https://platform.minimax.io/docs/api-reference/text-anthropic-api documents adaptive thinking,
|
|
75
|
+
// but not output_config.effort.
|
|
76
|
+
effortLevels: [],
|
|
77
|
+
thinking: "enabled",
|
|
78
|
+
verified: true,
|
|
79
|
+
notes: "Uses the thinking parameter; M3 supports adaptive thinking.",
|
|
80
|
+
docsUrl: "https://platform.minimax.io/docs/api-reference/text-anthropic-api",
|
|
81
|
+
},
|
|
82
|
+
// Docs: https://help.aliyun.com/en/model-studio/claude-code
|
|
83
|
+
{
|
|
84
|
+
id: "dashscope-intl",
|
|
85
|
+
kind: "anthropic-compatible",
|
|
86
|
+
name: "Qwen (DashScope International)",
|
|
87
|
+
vendorUrl: "https://www.alibabacloud.com/product/model-studio",
|
|
88
|
+
anthropicBaseUrl: "https://dashscope-intl.aliyuncs.com/apps/anthropic",
|
|
89
|
+
authHeader: "authorization-bearer",
|
|
90
|
+
fallbackModels: [
|
|
91
|
+
{ id: "qwen3.8-max", name: "Qwen 3.8 Max" },
|
|
92
|
+
{ id: "qwen3.6-flash", name: "Qwen 3.6 Flash" },
|
|
93
|
+
],
|
|
94
|
+
// https://help.aliyun.com/en/model-studio/anthropic-api-messages documents enabled thinking;
|
|
95
|
+
// it explicitly does not support reasoning_effort on the Anthropic endpoint.
|
|
96
|
+
effortLevels: [],
|
|
97
|
+
thinking: "enabled",
|
|
98
|
+
verified: true,
|
|
99
|
+
notes: "Keys are region-specific. The Anthropic-compatible endpoint has no model-list endpoint.",
|
|
100
|
+
docsUrl: "https://help.aliyun.com/en/model-studio/claude-code",
|
|
101
|
+
},
|
|
102
|
+
// Docs: https://help.aliyun.com/en/model-studio/claude-code
|
|
103
|
+
{
|
|
104
|
+
id: "dashscope-cn",
|
|
105
|
+
kind: "anthropic-compatible",
|
|
106
|
+
name: "Qwen (DashScope China)",
|
|
107
|
+
vendorUrl: "https://bailian.console.aliyun.com/",
|
|
108
|
+
anthropicBaseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
|
|
109
|
+
authHeader: "authorization-bearer",
|
|
110
|
+
fallbackModels: [
|
|
111
|
+
{ id: "qwen3.8-max", name: "Qwen 3.8 Max" },
|
|
112
|
+
{ id: "qwen3.6-flash", name: "Qwen 3.6 Flash" },
|
|
113
|
+
],
|
|
114
|
+
// https://help.aliyun.com/en/model-studio/anthropic-api-messages documents enabled thinking;
|
|
115
|
+
// it explicitly does not support reasoning_effort on the Anthropic endpoint.
|
|
116
|
+
effortLevels: [],
|
|
117
|
+
thinking: "enabled",
|
|
118
|
+
verified: true,
|
|
119
|
+
notes: "Keys are region-specific. The Anthropic-compatible endpoint has no model-list endpoint.",
|
|
120
|
+
docsUrl: "https://help.aliyun.com/en/model-studio/claude-code",
|
|
121
|
+
},
|
|
122
|
+
// Docs: https://openrouter.ai/docs/cookbook/coding-agents/claude-code-integration
|
|
123
|
+
{
|
|
124
|
+
id: "openrouter",
|
|
125
|
+
kind: "anthropic-compatible",
|
|
126
|
+
name: "OpenRouter",
|
|
127
|
+
vendorUrl: "https://openrouter.ai/",
|
|
128
|
+
anthropicBaseUrl: "https://openrouter.ai/api",
|
|
129
|
+
authHeader: "authorization-bearer",
|
|
130
|
+
modelsUrl: "https://openrouter.ai/api/v1/models",
|
|
131
|
+
modelsAuthHeader: "authorization-bearer",
|
|
132
|
+
// Ids verified against GET /api/v1/models on 2026-09-13 (the "-latest" aliases do not exist there).
|
|
133
|
+
fallbackModels: [
|
|
134
|
+
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5" },
|
|
135
|
+
{ id: "anthropic/claude-opus-5", name: "Claude Opus 5" },
|
|
136
|
+
],
|
|
137
|
+
// https://openrouter.ai/docs/api/api-reference/anthropic-messages/create-a-message supports
|
|
138
|
+
// provider passthrough; per-model capabilities vary, so low/medium/high is the conservative default.
|
|
139
|
+
effortLevels: ["low", "medium", "high"],
|
|
140
|
+
thinking: "enabled",
|
|
141
|
+
verified: true,
|
|
142
|
+
notes: "The Anthropic skin passes through thinking blocks and native tool use.",
|
|
143
|
+
docsUrl: "https://openrouter.ai/docs/cookbook/coding-agents/claude-code-integration",
|
|
144
|
+
},
|
|
145
|
+
// Docs: https://docs.x.ai/docs/guides/reasoning
|
|
146
|
+
{
|
|
147
|
+
id: "xai-grok",
|
|
148
|
+
kind: "openai-compatible",
|
|
149
|
+
name: "xAI Grok",
|
|
150
|
+
vendorUrl: "https://x.ai/",
|
|
151
|
+
anthropicBaseUrl: "https://api.x.ai/v1",
|
|
152
|
+
authHeader: "authorization-bearer",
|
|
153
|
+
modelsUrl: "https://api.x.ai/v1/models",
|
|
154
|
+
modelsAuthHeader: "authorization-bearer",
|
|
155
|
+
wire: "responses",
|
|
156
|
+
// https://docs.x.ai/docs/guides/reasoning documents low/medium/high/xhigh but does not publish a stable catalog id here.
|
|
157
|
+
fallbackModels: [],
|
|
158
|
+
effortLevels: ["low", "medium", "high", "xhigh"],
|
|
159
|
+
thinking: "none",
|
|
160
|
+
verified: true,
|
|
161
|
+
notes: "Responses supports reasoning.effort; Chat Completions may vary by model. Model discovery is used instead of a stale fallback id.",
|
|
162
|
+
docsUrl: "https://docs.x.ai/docs/guides/reasoning",
|
|
163
|
+
},
|
|
164
|
+
// Docs: https://docs.mistral.ai/api/endpoint/chat and https://docs.mistral.ai/api/endpoint/models
|
|
165
|
+
{
|
|
166
|
+
id: "mistral",
|
|
167
|
+
kind: "openai-compatible",
|
|
168
|
+
name: "Mistral AI",
|
|
169
|
+
vendorUrl: "https://mistral.ai/",
|
|
170
|
+
anthropicBaseUrl: "https://api.mistral.ai/v1",
|
|
171
|
+
authHeader: "authorization-bearer",
|
|
172
|
+
modelsUrl: "https://api.mistral.ai/v1/models",
|
|
173
|
+
modelsAuthHeader: "authorization-bearer",
|
|
174
|
+
fallbackModels: [],
|
|
175
|
+
effortLevels: [],
|
|
176
|
+
thinking: "none",
|
|
177
|
+
verified: true,
|
|
178
|
+
notes: "Official Chat Completions and model-list endpoints are documented; no provider-wide reasoning-effort contract is assumed.",
|
|
179
|
+
docsUrl: "https://docs.mistral.ai/api/endpoint/chat",
|
|
180
|
+
},
|
|
181
|
+
// Docs: https://console.groq.com/docs/openai
|
|
182
|
+
{
|
|
183
|
+
id: "groq",
|
|
184
|
+
kind: "openai-compatible",
|
|
185
|
+
name: "Groq",
|
|
186
|
+
vendorUrl: "https://groq.com/",
|
|
187
|
+
anthropicBaseUrl: "https://api.groq.com/openai/v1",
|
|
188
|
+
authHeader: "authorization-bearer",
|
|
189
|
+
modelsUrl: "https://api.groq.com/openai/v1/models",
|
|
190
|
+
modelsAuthHeader: "authorization-bearer",
|
|
191
|
+
fallbackModels: [],
|
|
192
|
+
effortLevels: [],
|
|
193
|
+
thinking: "none",
|
|
194
|
+
verified: true,
|
|
195
|
+
notes: "OpenAI compatibility is documented; prompt-cache and provider-wide reasoning support are not documented here.",
|
|
196
|
+
docsUrl: "https://console.groq.com/docs/openai",
|
|
197
|
+
},
|
|
198
|
+
// Docs: https://docs.together.ai/docs/openai-api-compatibility
|
|
199
|
+
{
|
|
200
|
+
id: "together",
|
|
201
|
+
kind: "openai-compatible",
|
|
202
|
+
name: "Together AI",
|
|
203
|
+
vendorUrl: "https://www.together.ai/",
|
|
204
|
+
anthropicBaseUrl: "https://api.together.xyz/v1",
|
|
205
|
+
authHeader: "authorization-bearer",
|
|
206
|
+
modelsUrl: "https://api.together.xyz/v1/models",
|
|
207
|
+
modelsAuthHeader: "authorization-bearer",
|
|
208
|
+
fallbackModels: [],
|
|
209
|
+
// https://docs.together.ai/docs/openai-api-compatibility documents low/medium/high for GPT-OSS only.
|
|
210
|
+
effortLevels: ["low", "medium", "high"],
|
|
211
|
+
thinking: "none",
|
|
212
|
+
verified: true,
|
|
213
|
+
notes: "reasoning_effort is documented only for GPT-OSS models; select a model that supports it.",
|
|
214
|
+
docsUrl: "https://docs.together.ai/docs/openai-api-compatibility",
|
|
215
|
+
},
|
|
216
|
+
// Docs: https://docs.fireworks.ai/tools-sdks/openai-compatibility
|
|
217
|
+
{
|
|
218
|
+
id: "fireworks",
|
|
219
|
+
kind: "openai-compatible",
|
|
220
|
+
name: "Fireworks AI",
|
|
221
|
+
vendorUrl: "https://fireworks.ai/",
|
|
222
|
+
anthropicBaseUrl: "https://api.fireworks.ai/inference/v1",
|
|
223
|
+
authHeader: "authorization-bearer",
|
|
224
|
+
modelsUrl: "https://api.fireworks.ai/inference/v1/models",
|
|
225
|
+
modelsAuthHeader: "authorization-bearer",
|
|
226
|
+
fallbackModels: [],
|
|
227
|
+
effortLevels: [],
|
|
228
|
+
thinking: "none",
|
|
229
|
+
verified: true,
|
|
230
|
+
notes: "Chat Completions and streaming usage are documented; model discovery avoids stale account/model identifiers.",
|
|
231
|
+
docsUrl: "https://docs.fireworks.ai/tools-sdks/openai-compatibility",
|
|
232
|
+
},
|
|
233
|
+
// Docs: https://docs.ollama.com/api/openai-compatibility
|
|
234
|
+
{
|
|
235
|
+
id: "ollama",
|
|
236
|
+
kind: "openai-compatible",
|
|
237
|
+
name: "Ollama (local)",
|
|
238
|
+
vendorUrl: "https://ollama.com/",
|
|
239
|
+
anthropicBaseUrl: "http://127.0.0.1:11434/v1",
|
|
240
|
+
authHeader: "authorization-bearer",
|
|
241
|
+
modelsUrl: "http://127.0.0.1:11434/v1/models",
|
|
242
|
+
modelsAuthHeader: "authorization-bearer",
|
|
243
|
+
fallbackModels: [],
|
|
244
|
+
effortLevels: [],
|
|
245
|
+
thinking: "none",
|
|
246
|
+
verified: true,
|
|
247
|
+
notes: "No API key is required locally. Both stateless Responses and Chat Completions are documented; cache behavior is not documented.",
|
|
248
|
+
docsUrl: "https://docs.ollama.com/api/openai-compatibility",
|
|
249
|
+
},
|
|
250
|
+
// Docs: https://lmstudio.ai/docs/developer/openai-compat/models
|
|
251
|
+
{
|
|
252
|
+
id: "lmstudio",
|
|
253
|
+
kind: "openai-compatible",
|
|
254
|
+
name: "LM Studio (local)",
|
|
255
|
+
vendorUrl: "https://lmstudio.ai/",
|
|
256
|
+
anthropicBaseUrl: "http://127.0.0.1:1234/v1",
|
|
257
|
+
authHeader: "authorization-bearer",
|
|
258
|
+
modelsUrl: "http://127.0.0.1:1234/v1/models",
|
|
259
|
+
modelsAuthHeader: "authorization-bearer",
|
|
260
|
+
fallbackModels: [],
|
|
261
|
+
effortLevels: [],
|
|
262
|
+
thinking: "none",
|
|
263
|
+
verified: true,
|
|
264
|
+
notes: "The local OpenAI-compatible model list and Chat Completions APIs are documented; cache and Responses support are not assumed.",
|
|
265
|
+
docsUrl: "https://lmstudio.ai/docs/developer/openai-compat/models",
|
|
266
|
+
},
|
|
267
|
+
];
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Claude Code OAuth observed while transparently passing a real CLI request. Kept in RAM and,
|
|
2
|
+
// when a file path is given, mirrored to that file (mode 0600, owner's home) so the OpenAI ingress
|
|
3
|
+
// keeps working right after a router restart or a reboot — otherwise nothing works until the next
|
|
4
|
+
// Code-tab request happens to pass through. No diagnostics or listing surface exists.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
export const OBSERVED_CLAUDE_CODE_TTL_MS = 12 * 60 * 60 * 1_000;
|
|
7
|
+
function isObservedHeader(name) {
|
|
8
|
+
const lower = name.toLowerCase();
|
|
9
|
+
return lower === "authorization"
|
|
10
|
+
|| lower === "anthropic-beta"
|
|
11
|
+
|| lower === "anthropic-version"
|
|
12
|
+
|| lower === "user-agent"
|
|
13
|
+
|| lower === "x-app"
|
|
14
|
+
|| lower.startsWith("x-stainless-")
|
|
15
|
+
|| lower.startsWith("anthropic-client-");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Stores the latest valid CLI Authorization plus its protocol-identifying headers in RAM.
|
|
19
|
+
* Callers receive no inspection or listing API: getFresh() exists solely for direct forwarding.
|
|
20
|
+
*/
|
|
21
|
+
export class ObservedClaudeCodeAuth {
|
|
22
|
+
snapshot = null;
|
|
23
|
+
file;
|
|
24
|
+
constructor(file) {
|
|
25
|
+
this.file = file ?? null;
|
|
26
|
+
if (this.file) {
|
|
27
|
+
try {
|
|
28
|
+
const raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
29
|
+
if (raw && typeof raw.authorization === "string" && raw.headers && typeof raw.observedAt === "number") {
|
|
30
|
+
this.snapshot = { authorization: raw.authorization, headers: Object.freeze({ ...raw.headers }), observedAt: raw.observedAt };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* no file yet or unreadable: start empty */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
persist() {
|
|
39
|
+
if (!this.file || !this.snapshot)
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
43
|
+
fs.writeFileSync(tmp, JSON.stringify(this.snapshot), { mode: 0o600 });
|
|
44
|
+
fs.renameSync(tmp, this.file);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* best effort */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
observe(rawHeaders, observedAt = Date.now()) {
|
|
51
|
+
const headers = {};
|
|
52
|
+
let authorization = null;
|
|
53
|
+
for (let index = 0; index + 1 < rawHeaders.length; index += 2) {
|
|
54
|
+
const name = rawHeaders[index];
|
|
55
|
+
const value = rawHeaders[index + 1];
|
|
56
|
+
if (!isObservedHeader(name))
|
|
57
|
+
continue;
|
|
58
|
+
if (name.toLowerCase() === "authorization") {
|
|
59
|
+
if (!/^Bearer\s+\S+$/i.test(value))
|
|
60
|
+
continue;
|
|
61
|
+
authorization = value;
|
|
62
|
+
}
|
|
63
|
+
headers[name] = value;
|
|
64
|
+
}
|
|
65
|
+
if (!authorization)
|
|
66
|
+
return;
|
|
67
|
+
const changed = !this.snapshot || this.snapshot.authorization !== authorization || observedAt - this.snapshot.observedAt > 5 * 60_000;
|
|
68
|
+
this.snapshot = {
|
|
69
|
+
authorization,
|
|
70
|
+
headers: Object.freeze({ ...headers }),
|
|
71
|
+
observedAt,
|
|
72
|
+
};
|
|
73
|
+
if (changed)
|
|
74
|
+
this.persist();
|
|
75
|
+
}
|
|
76
|
+
getFresh(now = Date.now()) {
|
|
77
|
+
const snapshot = this.snapshot;
|
|
78
|
+
if (!snapshot)
|
|
79
|
+
return null;
|
|
80
|
+
if (now < snapshot.observedAt || now - snapshot.observedAt >= OBSERVED_CLAUDE_CODE_TTL_MS) {
|
|
81
|
+
this.snapshot = null;
|
|
82
|
+
if (this.file)
|
|
83
|
+
fs.rmSync(this.file, { force: true });
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return snapshot;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ClaudeRipple-owned storage for a Claude subscription credential of our own: either a long-lived
|
|
2
|
+
// `claude setup-token` token or an OAuth grant obtained by `clauderipple claude-login` (browser
|
|
3
|
+
// flow, PKCE). This is intentionally separate from Claude Code's own credential stores, which are
|
|
4
|
+
// only ever read.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
export function claudeAuthPath(home) {
|
|
8
|
+
return path.join(home, "claude-auth.json");
|
|
9
|
+
}
|
|
10
|
+
export function readClaudeAuthFile(home) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = JSON.parse(fs.readFileSync(claudeAuthPath(home), "utf8"));
|
|
13
|
+
if (typeof raw.token !== "string" || raw.token.length === 0 || typeof raw.createdAt !== "string")
|
|
14
|
+
return null;
|
|
15
|
+
if (raw.source === "setup-token")
|
|
16
|
+
return { token: raw.token, createdAt: raw.createdAt, source: "setup-token" };
|
|
17
|
+
if (raw.source === "oauth" && typeof raw.refreshToken === "string" && raw.refreshToken.length > 0 && typeof raw.expiresAt === "number" && Number.isFinite(raw.expiresAt)) {
|
|
18
|
+
return { token: raw.token, createdAt: raw.createdAt, source: "oauth", refreshToken: raw.refreshToken, expiresAt: raw.expiresAt };
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function writeClaudeAuthFile(home, value) {
|
|
27
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
28
|
+
const file = claudeAuthPath(home);
|
|
29
|
+
fs.writeFileSync(file, JSON.stringify(value) + "\n", { mode: 0o600 });
|
|
30
|
+
fs.chmodSync(file, 0o600);
|
|
31
|
+
}
|
|
32
|
+
export function saveClaudeAuthFile(home, token, createdAt = new Date().toISOString()) {
|
|
33
|
+
writeClaudeAuthFile(home, { token, createdAt, source: "setup-token" });
|
|
34
|
+
}
|
|
35
|
+
export function saveClaudeOAuthFile(home, grant, createdAt = new Date().toISOString()) {
|
|
36
|
+
writeClaudeAuthFile(home, { token: grant.accessToken, createdAt, source: "oauth", refreshToken: grant.refreshToken, expiresAt: grant.expiresAt });
|
|
37
|
+
}
|
|
38
|
+
export function removeClaudeAuthFile(home) {
|
|
39
|
+
try {
|
|
40
|
+
fs.unlinkSync(claudeAuthPath(home));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code === "ENOENT")
|
|
45
|
+
return false;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Anthropic native provider used by the OpenAI ingress.
|
|
2
|
+
// `claude-code` auth borrows Claude Code's credential read-only. It never refreshes, writes, or
|
|
3
|
+
// logs a token because refresh-token rotation would invalidate Claude Code's own credential.
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import {} from "./anthropic-observed.js";
|
|
10
|
+
import { readClaudeAuthFile, saveClaudeOAuthFile } from "./anthropic-token-file.js";
|
|
11
|
+
import { CLAUDE_OAUTH, refreshClaudeOAuth } from "./claude-oauth.js";
|
|
12
|
+
export const CLAUDE_CODE_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20";
|
|
13
|
+
export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
14
|
+
const KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
15
|
+
const CACHE_MS = 60_000;
|
|
16
|
+
function claudeConfigDir() {
|
|
17
|
+
return path.join(os.homedir(), ".claude");
|
|
18
|
+
}
|
|
19
|
+
export function readClaudeCodeKeychain() {
|
|
20
|
+
if (process.platform !== "darwin")
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
return execFileSync("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000 }).trim();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function readClaudeCodeCredentialsFile() {
|
|
30
|
+
try {
|
|
31
|
+
return fs.readFileSync(path.join(claudeConfigDir(), ".credentials.json"), "utf8").trim();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Keychain first on macOS, then Claude Code's credential-file fallback. */
|
|
38
|
+
export function readClaudeCodeCredentials() {
|
|
39
|
+
const keychain = readClaudeCodeKeychain();
|
|
40
|
+
return parseClaudeCodeCredentials(keychain) ? keychain : readClaudeCodeCredentialsFile();
|
|
41
|
+
}
|
|
42
|
+
export function parseClaudeCodeCredentials(raw) {
|
|
43
|
+
if (!raw)
|
|
44
|
+
return null;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(raw);
|
|
47
|
+
const oauth = parsed.claudeAiOauth;
|
|
48
|
+
if (typeof oauth?.accessToken !== "string" || oauth.accessToken.length === 0 || typeof oauth.expiresAt !== "number" || !Number.isFinite(oauth.expiresAt))
|
|
49
|
+
return null;
|
|
50
|
+
return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt };
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export class ClaudeCodeCredentialStore {
|
|
57
|
+
cached = null;
|
|
58
|
+
checkedAt = 0;
|
|
59
|
+
read;
|
|
60
|
+
now;
|
|
61
|
+
constructor(read = readClaudeCodeCredentials, now = Date.now) {
|
|
62
|
+
this.read = read;
|
|
63
|
+
this.now = now;
|
|
64
|
+
}
|
|
65
|
+
/** Valid credential or a client-safe error. This deliberately never refreshes it. */
|
|
66
|
+
get() {
|
|
67
|
+
const now = this.now();
|
|
68
|
+
if (!this.cached || now - this.checkedAt >= CACHE_MS) {
|
|
69
|
+
this.cached = parseClaudeCodeCredentials(this.read());
|
|
70
|
+
this.checkedAt = now;
|
|
71
|
+
}
|
|
72
|
+
if (!this.cached)
|
|
73
|
+
return new Error("Claude Code login unavailable — open Claude Code and sign in");
|
|
74
|
+
if (now >= this.cached.expiresAt)
|
|
75
|
+
return new Error("Claude Code login expired — open Claude Code once to refresh");
|
|
76
|
+
return this.cached;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Selects a Claude Code subscription credential without refreshing or mutating Claude Code's login.
|
|
81
|
+
* A process-local observed header snapshot wins; all non-observed token sources use the normal OAuth wire shape.
|
|
82
|
+
*/
|
|
83
|
+
export class ClaudeCodeAuthStore {
|
|
84
|
+
credentials;
|
|
85
|
+
observed;
|
|
86
|
+
home;
|
|
87
|
+
env;
|
|
88
|
+
now;
|
|
89
|
+
fetchImpl;
|
|
90
|
+
log;
|
|
91
|
+
refreshing = null;
|
|
92
|
+
constructor(credentials = new ClaudeCodeCredentialStore(), options = {}) {
|
|
93
|
+
this.credentials = credentials;
|
|
94
|
+
this.observed = options.observed;
|
|
95
|
+
this.home = options.home ?? (process.env.CLAUDERIPPLE_HOME?.trim() || path.join(os.homedir(), ".clauderipple"));
|
|
96
|
+
this.env = options.env ?? process.env;
|
|
97
|
+
this.now = options.now ?? Date.now;
|
|
98
|
+
this.fetchImpl = options.fetch;
|
|
99
|
+
this.log = options.log ?? (() => { });
|
|
100
|
+
}
|
|
101
|
+
get() {
|
|
102
|
+
const observed = this.observed?.getFresh(this.now());
|
|
103
|
+
if (observed)
|
|
104
|
+
return { source: "observed", observed };
|
|
105
|
+
const environment = this.env.CLAUDE_CODE_OAUTH_TOKEN?.trim();
|
|
106
|
+
if (environment)
|
|
107
|
+
return { source: "env", credentials: { accessToken: environment, expiresAt: Number.POSITIVE_INFINITY } };
|
|
108
|
+
const stored = this.credentials.get();
|
|
109
|
+
if (!(stored instanceof Error))
|
|
110
|
+
return { source: "stored", credentials: stored };
|
|
111
|
+
const tokenFile = readClaudeAuthFile(this.home);
|
|
112
|
+
if (tokenFile?.source === "setup-token")
|
|
113
|
+
return { source: "token-file", credentials: { accessToken: tokenFile.token, expiresAt: Number.POSITIVE_INFINITY } };
|
|
114
|
+
if (tokenFile?.source === "oauth") {
|
|
115
|
+
if (this.now() >= tokenFile.expiresAt)
|
|
116
|
+
return new Error("ClaudeRipple's Claude sign-in expired and could not be refreshed — connect the subscription again");
|
|
117
|
+
return { source: "token-file", credentials: { accessToken: tokenFile.token, expiresAt: tokenFile.expiresAt } };
|
|
118
|
+
}
|
|
119
|
+
return stored;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Refreshes our own OAuth grant when it is about to expire. Only ClaudeRipple's file is ever
|
|
123
|
+
* written; Claude Code's credential is never refreshed (rotation would invalidate its copy).
|
|
124
|
+
* Concurrent callers share one refresh. Failures are logged and leave the file as it was, so
|
|
125
|
+
* get() reports the expiry once it arrives.
|
|
126
|
+
*/
|
|
127
|
+
refreshIfNeeded() {
|
|
128
|
+
if (this.refreshing)
|
|
129
|
+
return this.refreshing;
|
|
130
|
+
// Higher-precedence sources make the file irrelevant; do not touch the network for it.
|
|
131
|
+
if (this.observed?.getFresh(this.now()) || this.env.CLAUDE_CODE_OAUTH_TOKEN?.trim() || !(this.credentials.get() instanceof Error))
|
|
132
|
+
return Promise.resolve();
|
|
133
|
+
const tokenFile = readClaudeAuthFile(this.home);
|
|
134
|
+
if (tokenFile?.source !== "oauth" || this.now() < tokenFile.expiresAt - CLAUDE_OAUTH.refreshLeadMs)
|
|
135
|
+
return Promise.resolve();
|
|
136
|
+
this.refreshing = refreshClaudeOAuth(tokenFile.refreshToken, { ...(this.fetchImpl ? { fetch: this.fetchImpl } : {}), now: this.now })
|
|
137
|
+
.then((grant) => {
|
|
138
|
+
saveClaudeOAuthFile(this.home, grant, tokenFile.createdAt);
|
|
139
|
+
this.log(`claude oauth: access token refreshed, valid until ${new Date(grant.expiresAt).toISOString()}`);
|
|
140
|
+
}, (error) => {
|
|
141
|
+
this.log(`claude oauth: refresh failed: ${error.message}`);
|
|
142
|
+
})
|
|
143
|
+
.finally(() => {
|
|
144
|
+
this.refreshing = null;
|
|
145
|
+
});
|
|
146
|
+
return this.refreshing;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Returns only the currently usable credential source. This never returns a credential,
|
|
150
|
+
* tests validity using the same precedence as get(), and does not refresh anything.
|
|
151
|
+
*/
|
|
152
|
+
describeSource() {
|
|
153
|
+
if (this.observed?.getFresh(this.now()))
|
|
154
|
+
return "observed";
|
|
155
|
+
if (this.env.CLAUDE_CODE_OAUTH_TOKEN?.trim())
|
|
156
|
+
return "env";
|
|
157
|
+
const keychain = parseClaudeCodeCredentials(readClaudeCodeKeychain());
|
|
158
|
+
if (keychain && this.now() < keychain.expiresAt)
|
|
159
|
+
return "keychain";
|
|
160
|
+
const credentialsFile = parseClaudeCodeCredentials(readClaudeCodeCredentialsFile());
|
|
161
|
+
if (credentialsFile && this.now() < credentialsFile.expiresAt)
|
|
162
|
+
return "credentials-file";
|
|
163
|
+
return readClaudeAuthFile(this.home) ? "token-file" : null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Prefix custom tool names for subscription OAuth; builtin Anthropic tools stay unchanged. */
|
|
167
|
+
export function toClaudeCodeToolName(name) {
|
|
168
|
+
return /^(custom_|web_search$|code_execution$|text_editor$|computer$)/i.test(name) ? name : `custom_${name}`;
|
|
169
|
+
}
|
|
170
|
+
export function fromClaudeCodeToolName(name) {
|
|
171
|
+
return name.startsWith("custom_") ? name.slice("custom_".length) : name;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Construct native Anthropic auth headers. OAuth wire requirements are behaviorally measured,
|
|
175
|
+
* while API-key mode follows the public Messages API header convention.
|
|
176
|
+
*/
|
|
177
|
+
/** Reuses an observed CLI header set without adding manufactured fingerprint fields. */
|
|
178
|
+
export function observedAnthropicHeaders(observed) {
|
|
179
|
+
return { ...observed.headers };
|
|
180
|
+
}
|
|
181
|
+
export function nativeAnthropicHeaders(provider, credentials) {
|
|
182
|
+
if (provider.auth === "api-key") {
|
|
183
|
+
const apiKey = provider.apiKey ?? process.env.ANTHROPIC_API_KEY;
|
|
184
|
+
if (!apiKey)
|
|
185
|
+
throw new Error("Anthropic API key missing — set providers.<name>.apiKey or ANTHROPIC_API_KEY");
|
|
186
|
+
return { "x-api-key": apiKey, "anthropic-version": "2023-06-01" };
|
|
187
|
+
}
|
|
188
|
+
if (!credentials)
|
|
189
|
+
throw new Error("Claude Code login unavailable — open Claude Code and sign in");
|
|
190
|
+
return {
|
|
191
|
+
authorization: `Bearer ${credentials.accessToken}`,
|
|
192
|
+
"anthropic-version": "2023-06-01",
|
|
193
|
+
"anthropic-beta": CLAUDE_CODE_OAUTH_BETA,
|
|
194
|
+
"user-agent": "claude-cli/2.1.266 (external, cli)",
|
|
195
|
+
"x-app": "cli",
|
|
196
|
+
"x-stainless-lang": "js",
|
|
197
|
+
"x-stainless-package-version": "0.74.0",
|
|
198
|
+
"x-stainless-os": process.platform,
|
|
199
|
+
"x-stainless-arch": process.arch,
|
|
200
|
+
"x-claude-code-session-id": crypto.randomUUID(),
|
|
201
|
+
"x-client-request-id": crypto.randomUUID(),
|
|
202
|
+
};
|
|
203
|
+
}
|