jeo-code 0.6.22 → 0.6.24
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 +26 -0
- package/README.ja.md +6 -2
- package/README.ko.md +6 -2
- package/README.md +6 -2
- package/README.zh.md +6 -2
- package/package.json +1 -1
- package/src/agent/config-schema.ts +12 -0
- package/src/agent/session.ts +10 -3
- package/src/agent/state.ts +19 -14
- package/src/ai/index.ts +1 -0
- package/src/ai/model-catalog.ts +121 -1
- package/src/ai/model-discovery.ts +55 -3
- package/src/ai/model-manager.ts +43 -11
- package/src/ai/model-registry.ts +2 -0
- package/src/ai/provider-status.ts +45 -7
- package/src/ai/providers/anthropic-compatible.ts +27 -0
- package/src/ai/providers/anthropic.ts +3 -1
- package/src/ai/providers/antigravity.ts +31 -6
- package/src/ai/providers/gemini.ts +45 -4
- package/src/ai/providers/kimi.ts +18 -0
- package/src/ai/providers/lmstudio.ts +8 -0
- package/src/ai/providers/ollama.ts +17 -5
- package/src/ai/providers/openai-compatible-catalog.ts +83 -0
- package/src/ai/providers/openai-compatible.ts +34 -0
- package/src/ai/providers/openai-responses.ts +11 -0
- package/src/ai/providers/openai.ts +115 -7
- package/src/ai/providers/xai.ts +18 -0
- package/src/ai/register-providers.ts +18 -0
- package/src/ai/think-tags.ts +84 -0
- package/src/ai/types.ts +11 -1
- package/src/auth/flows/index.ts +3 -3
- package/src/auth/index.ts +4 -1
- package/src/auth/oauth.ts +3 -3
- package/src/auth/refresh.ts +5 -0
- package/src/auth/storage.ts +12 -1
- package/src/commands/auth.ts +21 -2
- package/src/commands/launch/flags.ts +5 -1
- package/src/commands/launch/input.ts +13 -0
- package/src/commands/launch.ts +307 -26
- package/src/commands/setup.ts +3 -2
- package/src/tui/app.ts +61 -41
- package/src/tui/components/ascii-art.ts +91 -124
- package/src/tui/components/autocomplete.ts +16 -0
- package/src/tui/components/forge.ts +1 -1
- package/src/tui/components/provider-picker.ts +162 -0
- package/src/tui/components/slash.ts +2 -2
- package/src/tui/components/transcript.ts +7 -0
- package/src/tui/components/welcome.ts +8 -8
- package/src/tui/components/width.ts +21 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ProviderAdapter, CallOptions, ProviderName } from "../types";
|
|
2
|
+
import type { Credential } from "../../auth";
|
|
3
|
+
import { openaiAdapter } from "./openai";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Factory for OpenAI-compatible providers (LM Studio, xAI/Grok, …). They all speak
|
|
7
|
+
* the same `/chat/completions` wire protocol, so each is a thin shim over
|
|
8
|
+
* `openaiAdapter`: strip the `<name>/` routing prefix, pin the base URL, and pass the
|
|
9
|
+
* credential (or force keyless for local servers that ignore auth). `keyless` keeps
|
|
10
|
+
* the openai adapter on plain /chat/completions (an oauth credential would divert to
|
|
11
|
+
* the Codex Responses backend).
|
|
12
|
+
*/
|
|
13
|
+
const KEYLESS: Credential = { kind: "none", provider: "openai" };
|
|
14
|
+
|
|
15
|
+
export function makeOpenAICompatibleAdapter(opts: { name: ProviderName; baseUrl: string; keyless?: boolean; thinkingFormat?: CallOptions["reasoningFormat"] }): ProviderAdapter {
|
|
16
|
+
const prefix = `${opts.name}/`;
|
|
17
|
+
const prep = (o: CallOptions): CallOptions => ({
|
|
18
|
+
...o,
|
|
19
|
+
model: o.model.startsWith(prefix) ? o.model.slice(prefix.length) : o.model,
|
|
20
|
+
baseUrl: o.baseUrl ?? opts.baseUrl,
|
|
21
|
+
// Carry the backend's native-reasoning enablement so openaiRequest can turn thinking
|
|
22
|
+
// on with the right param (gjc parity) — without it OpenRouter/Qwen models stay silent.
|
|
23
|
+
reasoningFormat: o.reasoningFormat ?? opts.thinkingFormat,
|
|
24
|
+
});
|
|
25
|
+
const credFor = (c: Credential): Credential => (opts.keyless ? KEYLESS : c);
|
|
26
|
+
return {
|
|
27
|
+
name: opts.name,
|
|
28
|
+
supportsNativeTools: openaiAdapter.supportsNativeTools,
|
|
29
|
+
call: (messages, options, credential) => openaiAdapter.call(messages, prep(options), credFor(credential)),
|
|
30
|
+
async *stream(messages, options, credential) {
|
|
31
|
+
yield* openaiAdapter.stream!(messages, prep(options), credFor(credential));
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -76,6 +76,17 @@ export function codexResponsesRequest(
|
|
|
76
76
|
// frame can show the model's thinking instead of a frozen "calling model (Ns)…".
|
|
77
77
|
payload.reasoning = { effort: options.reasoningEffort, summary: "auto" };
|
|
78
78
|
}
|
|
79
|
+
// OAuth → the undocumented ChatGPT/Codex backend (codex headers + account-id).
|
|
80
|
+
// API key → the public OpenAI Responses API (`/v1/responses`) with a plain Bearer.
|
|
81
|
+
// Both speak the same Responses schema (the body above), so only url+headers differ.
|
|
82
|
+
if (credential.kind === "api_key") {
|
|
83
|
+
const base = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
|
|
84
|
+
return {
|
|
85
|
+
url: `${base}/responses`,
|
|
86
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}`, accept: "text/event-stream" },
|
|
87
|
+
body: JSON.stringify(payload),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
79
90
|
const accountId = extractChatgptAccountId(token);
|
|
80
91
|
const headers: Record<string, string> = {
|
|
81
92
|
"content-type": "application/json",
|
|
@@ -4,6 +4,37 @@ import { readSse } from "../sse";
|
|
|
4
4
|
import { providerHttpError } from "./errors";
|
|
5
5
|
import { codexResponsesCall, codexResponsesStream } from "./openai-responses";
|
|
6
6
|
import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
|
|
7
|
+
import { createThinkSplitter } from "../think-tags";
|
|
8
|
+
|
|
9
|
+
/** True for OpenAI reasoning models (o-series + gpt-5+ family). Digit-count agnostic
|
|
10
|
+
* (gpt-6/o10 stay reasoning). Strips the `openai/` routing prefix first. */
|
|
11
|
+
export function isOpenAIReasoningModel(model: string): boolean {
|
|
12
|
+
const m = model.startsWith("openai/") ? model.slice(7) : model;
|
|
13
|
+
const gptMajor = m.match(/^gpt-(\d+)/);
|
|
14
|
+
return /^o\d/.test(m) || (gptMajor ? Number(gptMajor[1]) >= 5 : false);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** gjc-parity: write the backend-specific param that turns NATIVE reasoning ON, so the
|
|
18
|
+
* model streams thinking we can surface. Mutates `payload`. "openai" needs no param here
|
|
19
|
+
* (handled by `reasoning_effort` for true o-series/gpt-5 models). */
|
|
20
|
+
export function applyCompatThinking(
|
|
21
|
+
payload: Record<string, unknown>,
|
|
22
|
+
format: CallOptions["reasoningFormat"],
|
|
23
|
+
effort: NonNullable<CallOptions["reasoningEffort"]>,
|
|
24
|
+
): void {
|
|
25
|
+
switch (format) {
|
|
26
|
+
case "openrouter":
|
|
27
|
+
payload.reasoning = { effort };
|
|
28
|
+
break;
|
|
29
|
+
case "qwen":
|
|
30
|
+
payload.enable_thinking = true;
|
|
31
|
+
break;
|
|
32
|
+
case "zai":
|
|
33
|
+
payload.thinking = { type: "enabled" };
|
|
34
|
+
break;
|
|
35
|
+
// "openai" / undefined: no extra param (reasoning_effort path covers real OpenAI models).
|
|
36
|
+
}
|
|
37
|
+
}
|
|
7
38
|
|
|
8
39
|
export function openaiRequest(messages: Message[], options: CallOptions, credential: Credential, stream: boolean): { url: string; headers: Record<string, string>; body: string } {
|
|
9
40
|
const model = options.model.startsWith("openai/") ? options.model.slice(7) : options.model;
|
|
@@ -22,9 +53,10 @@ export function openaiRequest(messages: Message[], options: CallOptions, credent
|
|
|
22
53
|
: msg.content;
|
|
23
54
|
openaiMessages.push({ role: msg.role, content });
|
|
24
55
|
}
|
|
25
|
-
// Reasoning models (o-series, gpt-5 family) take max_completion_tokens + reasoning_effort
|
|
56
|
+
// Reasoning models (o-series, gpt-5+ family) take max_completion_tokens + reasoning_effort
|
|
26
57
|
// and reject temperature; classic chat models (gpt-4o, …) take max_tokens + temperature.
|
|
27
|
-
|
|
58
|
+
// Digit-count agnostic (gpt-6/o10 stay reasoning) — mirrors inferCatalogMetadata.
|
|
59
|
+
const isReasoning = isOpenAIReasoningModel(model);
|
|
28
60
|
const payload: Record<string, unknown> = {
|
|
29
61
|
model,
|
|
30
62
|
messages: openaiMessages,
|
|
@@ -36,6 +68,13 @@ export function openaiRequest(messages: Message[], options: CallOptions, credent
|
|
|
36
68
|
payload.temperature = options.temperature ?? 0.2;
|
|
37
69
|
payload.max_tokens = options.maxTokens ?? 4000;
|
|
38
70
|
}
|
|
71
|
+
// gjc parity — enable NATIVE reasoning per the backend's thinking format so the model
|
|
72
|
+
// actually emits reasoning (otherwise OpenRouter/Qwen/z.ai stay silent and the TUI has
|
|
73
|
+
// nothing to show). `reasoning_effort` (OpenAI-style) only suits o-series/gpt-5; other
|
|
74
|
+
// backends need their own param. Gated on a requested effort (off → no thinking).
|
|
75
|
+
if (options.reasoningEffort && !isReasoning) {
|
|
76
|
+
applyCompatThinking(payload, options.reasoningFormat, options.reasoningEffort);
|
|
77
|
+
}
|
|
39
78
|
if (stream) {
|
|
40
79
|
payload.stream = true;
|
|
41
80
|
payload.stream_options = { include_usage: true };
|
|
@@ -62,12 +101,56 @@ function emptyCompletionError(finishReason: string | undefined): Error {
|
|
|
62
101
|
return new Error(`OpenAI returned no content${finishReason ? ` (finish_reason=${finishReason})` : ""}${hint}.`);
|
|
63
102
|
}
|
|
64
103
|
|
|
104
|
+
/** A streamed `choices[].delta`. `reasoning` is `unknown` because OpenAI-compatible
|
|
105
|
+
* servers disagree on its shape: a plain string (OpenRouter/xAI), an object
|
|
106
|
+
* `{ text|content }`, or absent (the `reasoning_details[]` array carries it instead). */
|
|
107
|
+
export interface OpenAIDelta {
|
|
108
|
+
content?: string;
|
|
109
|
+
reasoning_content?: string;
|
|
110
|
+
reasoning_text?: string;
|
|
111
|
+
reasoning?: unknown;
|
|
112
|
+
reasoning_details?: { text?: string; content?: string }[];
|
|
113
|
+
tool_calls?: { index?: number; function?: { name?: string; arguments?: string } }[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Pull a reasoning-text delta out of the many OpenAI-compatible shapes. Returns the
|
|
117
|
+
* first non-empty of: `reasoning_content`, `reasoning_text`, a string/`{text|content}`
|
|
118
|
+
* `reasoning`, or the concatenated `reasoning_details[].text|content`. */
|
|
119
|
+
export function reasoningDeltaOf(delta: OpenAIDelta | undefined): string | undefined {
|
|
120
|
+
if (!delta) return undefined;
|
|
121
|
+
if (typeof delta.reasoning_content === "string" && delta.reasoning_content) return delta.reasoning_content;
|
|
122
|
+
if (typeof delta.reasoning_text === "string" && delta.reasoning_text) return delta.reasoning_text;
|
|
123
|
+
const r = delta.reasoning;
|
|
124
|
+
if (typeof r === "string" && r) return r;
|
|
125
|
+
if (r && typeof r === "object") {
|
|
126
|
+
const o = r as { text?: unknown; content?: unknown };
|
|
127
|
+
if (typeof o.text === "string" && o.text) return o.text;
|
|
128
|
+
if (typeof o.content === "string" && o.content) return o.content;
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(delta.reasoning_details)) {
|
|
131
|
+
const t = delta.reasoning_details
|
|
132
|
+
.map(x => (typeof x?.text === "string" ? x.text : typeof x?.content === "string" ? x.content : ""))
|
|
133
|
+
.join("");
|
|
134
|
+
if (t) return t;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
65
139
|
export const openaiAdapter: ProviderAdapter = {
|
|
66
140
|
name: "openai",
|
|
67
141
|
supportsNativeTools: true,
|
|
68
142
|
async call(messages, options, credential) {
|
|
69
143
|
// ChatGPT/Codex OAuth can't use /chat/completions — route to the Codex Responses backend.
|
|
70
144
|
if (credential.kind === "oauth") return codexResponsesCall(messages, options, credential);
|
|
145
|
+
// OpenAI reasoning models (o-series/gpt-5) expose reasoning ONLY via the Responses
|
|
146
|
+
// API — /chat/completions hides it. Use Responses for a real-OpenAI API key (no
|
|
147
|
+
// custom baseUrl); OpenAI-compatible servers (groq/xai/lmstudio/… set baseUrl) keep
|
|
148
|
+
// the chat path + reasoning_content. Fall back to chat if /responses is unavailable.
|
|
149
|
+
if (credential.kind === "api_key" && !options.baseUrl && isOpenAIReasoningModel(options.model)) {
|
|
150
|
+
try {
|
|
151
|
+
return await codexResponsesCall(messages, options, credential);
|
|
152
|
+
} catch { /* /responses unsupported for this model/account — fall through to chat */ }
|
|
153
|
+
}
|
|
71
154
|
const { url, headers, body } = openaiRequest(messages, options, credential, false);
|
|
72
155
|
const response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
|
|
73
156
|
if (!response.ok) throw await providerHttpError("OpenAI", response);
|
|
@@ -85,6 +168,18 @@ export const openaiAdapter: ProviderAdapter = {
|
|
|
85
168
|
yield* codexResponsesStream(messages, options, credential);
|
|
86
169
|
return;
|
|
87
170
|
}
|
|
171
|
+
// OpenAI reasoning models surface reasoning only via Responses (see call()). Pre-stream
|
|
172
|
+
// fallback: if it fails before any chunk, retry on chat completions (no regression).
|
|
173
|
+
if (credential.kind === "api_key" && !options.baseUrl && isOpenAIReasoningModel(options.model)) {
|
|
174
|
+
let started = false;
|
|
175
|
+
try {
|
|
176
|
+
for await (const chunk of codexResponsesStream(messages, options, credential)) { started = true; yield chunk; }
|
|
177
|
+
return;
|
|
178
|
+
} catch (e) {
|
|
179
|
+
if (started) throw e; // mid-stream failure — cannot safely restart on another endpoint
|
|
180
|
+
// else fall through to chat completions below
|
|
181
|
+
}
|
|
182
|
+
}
|
|
88
183
|
const { url, headers, body } = openaiRequest(messages, options, credential, true);
|
|
89
184
|
let response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
|
|
90
185
|
if (response.status === 400) {
|
|
@@ -102,19 +197,30 @@ export const openaiAdapter: ProviderAdapter = {
|
|
|
102
197
|
if (!response.body) return;
|
|
103
198
|
let yieldedAny = false;
|
|
104
199
|
let finishReason: string | undefined;
|
|
200
|
+
// Split inline <think>…</think> (DeepSeek-R1/Qwen-style local models) out of the
|
|
201
|
+
// visible answer and onto the reasoning channel. No-op for models that never emit it.
|
|
202
|
+
const think = createThinkSplitter(options.onReasoning);
|
|
105
203
|
const toolAcc = new Map<number, { name: string; args: string }>();
|
|
106
204
|
for await (const data of readSse(response.body)) {
|
|
107
|
-
let chunk: { choices?: { delta?:
|
|
205
|
+
let chunk: { choices?: { delta?: OpenAIDelta; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
|
|
108
206
|
try {
|
|
109
207
|
chunk = JSON.parse(data);
|
|
110
208
|
} catch {
|
|
111
209
|
continue;
|
|
112
210
|
}
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
211
|
+
const raw = chunk.choices?.[0]?.delta?.content;
|
|
212
|
+
if (raw) {
|
|
213
|
+
const visible = think.push(raw);
|
|
214
|
+
if (visible) {
|
|
215
|
+
yieldedAny = true;
|
|
216
|
+
yield visible;
|
|
217
|
+
}
|
|
117
218
|
}
|
|
219
|
+
// Structured reasoning channel (separate from `content`, so it bypasses the
|
|
220
|
+
// <think> splitter): handles string fields, an object `reasoning`, and the
|
|
221
|
+
// `reasoning_details[]` array form (OpenRouter/xAI/DeepSeek variants).
|
|
222
|
+
const reason = reasoningDeltaOf(chunk.choices?.[0]?.delta);
|
|
223
|
+
if (reason) options.onReasoning?.(reason);
|
|
118
224
|
const tcs = chunk.choices?.[0]?.delta?.tool_calls;
|
|
119
225
|
if (tcs) {
|
|
120
226
|
for (const tc of tcs) {
|
|
@@ -128,6 +234,8 @@ export const openaiAdapter: ProviderAdapter = {
|
|
|
128
234
|
if (chunk.choices?.[0]?.finish_reason) finishReason = chunk.choices[0].finish_reason;
|
|
129
235
|
if (chunk.usage) options.onUsage?.({ inputTokens: chunk.usage.prompt_tokens, outputTokens: chunk.usage.completion_tokens });
|
|
130
236
|
}
|
|
237
|
+
const trailing = think.flush();
|
|
238
|
+
if (trailing) { yieldedAny = true; yield trailing; }
|
|
131
239
|
// Native tool calls stream as tool_calls argument fragments — re-serialize once at end.
|
|
132
240
|
const envelope = serializeAccumulatedToolCalls(toolAcc);
|
|
133
241
|
if (envelope) { yieldedAny = true; yield envelope; }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Credential } from "../../auth";
|
|
2
|
+
import { makeOpenAICompatibleAdapter } from "./openai-compatible";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* xAI (Grok) — OpenAI-compatible cloud API at https://api.x.ai/v1, keyed by
|
|
6
|
+
* XAI_API_KEY (or `providers.xai`). The credential (an api_key bearer) is passed
|
|
7
|
+
* through; grok reasoning models (grok-4.3, grok-4-fast-*, grok-code-fast-1) stream
|
|
8
|
+
* reasoning via `reasoning_content`, which the openai adapter routes to onReasoning.
|
|
9
|
+
*/
|
|
10
|
+
export const XAI_BASE_URL = "https://api.x.ai/v1";
|
|
11
|
+
|
|
12
|
+
export const xaiAdapter = makeOpenAICompatibleAdapter({ name: "xai", baseUrl: XAI_BASE_URL });
|
|
13
|
+
|
|
14
|
+
/** Credential carrier for xAI calls — an api_key bearer (the adapter only reads the
|
|
15
|
+
* token); a keyless `none` when no key is set. */
|
|
16
|
+
export function xaiCredential(key: string | undefined): Credential {
|
|
17
|
+
return key ? { kind: "api_key", provider: "openai", token: key } : { kind: "none", provider: "openai" };
|
|
18
|
+
}
|
|
@@ -12,10 +12,28 @@ import { anthropicAdapter } from "./providers/anthropic";
|
|
|
12
12
|
import { openaiAdapter } from "./providers/openai";
|
|
13
13
|
import { geminiAdapter } from "./providers/gemini";
|
|
14
14
|
import { ollamaAdapter } from "./providers/ollama";
|
|
15
|
+
import { lmstudioAdapter } from "./providers/lmstudio";
|
|
16
|
+
import { xaiAdapter } from "./providers/xai";
|
|
15
17
|
import { antigravityAdapter } from "./providers/antigravity";
|
|
18
|
+
import { kimiAdapter } from "./providers/kimi";
|
|
19
|
+
import { makeOpenAICompatibleAdapter } from "./providers/openai-compatible";
|
|
20
|
+
import { makeAnthropicCompatibleAdapter } from "./providers/anthropic-compatible";
|
|
21
|
+
import { OPENAI_COMPAT_PROVIDERS } from "./providers/openai-compatible-catalog";
|
|
16
22
|
|
|
17
23
|
providerRegistry.register("anthropic", anthropicAdapter);
|
|
18
24
|
providerRegistry.register("openai", openaiAdapter);
|
|
19
25
|
providerRegistry.register("gemini", geminiAdapter);
|
|
20
26
|
providerRegistry.register("antigravity", antigravityAdapter);
|
|
21
27
|
providerRegistry.register("ollama", ollamaAdapter);
|
|
28
|
+
providerRegistry.register("lmstudio", lmstudioAdapter);
|
|
29
|
+
providerRegistry.register("xai", xaiAdapter);
|
|
30
|
+
providerRegistry.register("kimi", kimiAdapter);
|
|
31
|
+
|
|
32
|
+
// gjc-style data-driven providers: every catalog entry gets a thin factory adapter,
|
|
33
|
+
// selected by wire protocol. Add a provider by adding ONE catalog row.
|
|
34
|
+
for (const def of OPENAI_COMPAT_PROVIDERS) {
|
|
35
|
+
const adapter = def.protocol === "anthropic"
|
|
36
|
+
? makeAnthropicCompatibleAdapter({ name: def.name, baseUrl: def.baseUrl })
|
|
37
|
+
: makeOpenAICompatibleAdapter({ name: def.name, baseUrl: def.baseUrl, thinkingFormat: def.thinkingFormat });
|
|
38
|
+
providerRegistry.register(def.name, adapter);
|
|
39
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming `<think>…</think>` splitter for OpenAI-compatible / Ollama models.
|
|
3
|
+
*
|
|
4
|
+
* Many open/local reasoning models (DeepSeek-R1, Qwen "thinking", QwQ, …) do NOT
|
|
5
|
+
* expose a separate reasoning channel; they inline their chain-of-thought as
|
|
6
|
+
* `<think>…</think>` inside the normal content stream. Without splitting, that
|
|
7
|
+
* reasoning is dumped into the answer as literal text. This stateful splitter
|
|
8
|
+
* routes think-tag content to `onReasoning` (the dimmed live trace) and returns
|
|
9
|
+
* only the user-visible answer text — handling tags that straddle chunk
|
|
10
|
+
* boundaries, so it is safe to feed raw streamed deltas one at a time.
|
|
11
|
+
*
|
|
12
|
+
* Passthrough is near-free: text with no `<think>` tag flows through unchanged
|
|
13
|
+
* (only a trailing partial-tag fragment is briefly buffered).
|
|
14
|
+
*/
|
|
15
|
+
const OPEN = "<think>";
|
|
16
|
+
const CLOSE = "</think>";
|
|
17
|
+
|
|
18
|
+
/** Longest suffix of `s` that is a non-empty proper prefix of `tag` (0 if none). */
|
|
19
|
+
function partialTail(s: string, tag: string): number {
|
|
20
|
+
const max = Math.min(s.length, tag.length - 1);
|
|
21
|
+
for (let k = max; k > 0; k--) {
|
|
22
|
+
if (s.endsWith(tag.slice(0, k))) return k;
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ThinkSplitter {
|
|
28
|
+
/** Feed one streamed delta; returns the visible (answer) text to yield. */
|
|
29
|
+
push(delta: string): string;
|
|
30
|
+
/** Flush any buffered partial tag at stream end; returns trailing visible text. */
|
|
31
|
+
flush(): string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createThinkSplitter(onReasoning?: (delta: string) => void): ThinkSplitter {
|
|
35
|
+
let inThink = false;
|
|
36
|
+
let pending = ""; // a tail that might be the start of an OPEN/CLOSE tag
|
|
37
|
+
|
|
38
|
+
const push = (delta: string): string => {
|
|
39
|
+
let s = pending + delta;
|
|
40
|
+
pending = "";
|
|
41
|
+
let visible = "";
|
|
42
|
+
for (;;) {
|
|
43
|
+
if (!inThink) {
|
|
44
|
+
const idx = s.indexOf(OPEN);
|
|
45
|
+
if (idx === -1) {
|
|
46
|
+
const tail = partialTail(s, OPEN);
|
|
47
|
+
visible += s.slice(0, s.length - tail);
|
|
48
|
+
pending = s.slice(s.length - tail);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
visible += s.slice(0, idx);
|
|
52
|
+
s = s.slice(idx + OPEN.length);
|
|
53
|
+
inThink = true;
|
|
54
|
+
} else {
|
|
55
|
+
const idx = s.indexOf(CLOSE);
|
|
56
|
+
if (idx === -1) {
|
|
57
|
+
const tail = partialTail(s, CLOSE);
|
|
58
|
+
const think = s.slice(0, s.length - tail);
|
|
59
|
+
if (think) onReasoning?.(think);
|
|
60
|
+
pending = s.slice(s.length - tail);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
const think = s.slice(0, idx);
|
|
64
|
+
if (think) onReasoning?.(think);
|
|
65
|
+
s = s.slice(idx + CLOSE.length);
|
|
66
|
+
inThink = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return visible;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const flush = (): string => {
|
|
73
|
+
const out = pending;
|
|
74
|
+
pending = "";
|
|
75
|
+
// An unterminated tail is literal content: emit it on whichever channel was open.
|
|
76
|
+
if (inThink) {
|
|
77
|
+
if (out) onReasoning?.(out);
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return { push, flush };
|
|
84
|
+
}
|
package/src/ai/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Credential } from "../auth";
|
|
2
2
|
|
|
3
|
-
export type ProviderName = "anthropic" | "openai" | "gemini" | "antigravity" | "ollama";
|
|
3
|
+
export type ProviderName = "anthropic" | "openai" | "gemini" | "antigravity" | "ollama" | "lmstudio" | "xai" | "kimi" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax";
|
|
4
4
|
|
|
5
5
|
/** An image attached to a (user) message — base64 payload + IANA media type. */
|
|
6
6
|
export interface ImageAttachment {
|
|
@@ -17,6 +17,11 @@ export interface Message {
|
|
|
17
17
|
* these alongside `content`; history bookkeeping (compaction, transcripts)
|
|
18
18
|
* keeps treating `content` as the message body. */
|
|
19
19
|
images?: ImageAttachment[];
|
|
20
|
+
/** Persisted reasoning/thinking text for an assistant turn (the thought before the
|
|
21
|
+
* answer). Survives /resume + export so the durable record shows "think → answer".
|
|
22
|
+
* Display-only: NOT replayed to providers (anthropic/gemini thinking replay needs
|
|
23
|
+
* the original signed block, which the streaming path does not capture). */
|
|
24
|
+
reasoning?: string;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export interface Usage {
|
|
@@ -50,6 +55,11 @@ export interface CallOptions {
|
|
|
50
55
|
signal?: AbortSignal;
|
|
51
56
|
/** Reasoning effort for reasoning models (o-series / gpt-5), mapped from thinkingLevel. */
|
|
52
57
|
reasoningEffort?: "minimal" | "low" | "medium" | "high";
|
|
58
|
+
/** How an OpenAI-compatible backend enables/streams native reasoning (gjc parity):
|
|
59
|
+
* "openai" → `reasoning_effort`; "openrouter" → `reasoning: {effort}`; "qwen" →
|
|
60
|
+
* `enable_thinking: true`; "zai" → `thinking: {type:"enabled"}`. Set per provider by
|
|
61
|
+
* the openai-compatible factory; without it a model never emits reasoning to surface. */
|
|
62
|
+
reasoningFormat?: "openai" | "openrouter" | "qwen" | "zai";
|
|
53
63
|
/** Notified before each auto-retry backoff wait (rate limits / transient errors).
|
|
54
64
|
* NOT forwarded to provider adapters — consumed by the manager's retry layer. */
|
|
55
65
|
onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
|
package/src/auth/flows/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Per-provider OAuth login + refresh dispatch. */
|
|
2
|
-
import type { AuthProvider } from "../storage";
|
|
2
|
+
import type { AuthProvider, OAuthProvider } from "../storage";
|
|
3
3
|
import type { OAuthController, OAuthCredentials } from "../types";
|
|
4
4
|
import { loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
5
5
|
import { loginOpenAI, refreshOpenAIToken } from "./openai";
|
|
@@ -7,7 +7,7 @@ import { loginGoogle, refreshGoogleToken } from "./google";
|
|
|
7
7
|
import { loginAntigravity, refreshAntigravityToken } from "./antigravity";
|
|
8
8
|
|
|
9
9
|
export interface OAuthFlow {
|
|
10
|
-
readonly provider:
|
|
10
|
+
readonly provider: OAuthProvider;
|
|
11
11
|
readonly label: string;
|
|
12
12
|
/** Run the interactive browser/PKCE login. */
|
|
13
13
|
login(ctrl: OAuthController): Promise<OAuthCredentials>;
|
|
@@ -19,7 +19,7 @@ export interface OAuthFlow {
|
|
|
19
19
|
readonly note?: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export const OAUTH_FLOW_REGISTRY: Record<
|
|
22
|
+
export const OAUTH_FLOW_REGISTRY: Record<OAuthProvider, OAuthFlow> = {
|
|
23
23
|
anthropic: {
|
|
24
24
|
provider: "anthropic",
|
|
25
25
|
label: "Anthropic (Claude Pro/Max)",
|
package/src/auth/index.ts
CHANGED
|
@@ -6,8 +6,11 @@ export {
|
|
|
6
6
|
setOauthCredential,
|
|
7
7
|
clearOauthToken,
|
|
8
8
|
setApiKey,
|
|
9
|
+
isOAuthProvider,
|
|
10
|
+
OAUTH_PROVIDERS,
|
|
11
|
+
API_KEY_ONLY_PROVIDERS,
|
|
9
12
|
} from "./storage";
|
|
10
|
-
export type { AuthProvider, Credential, AuthSnapshot } from "./storage";
|
|
13
|
+
export type { AuthProvider, OAuthProvider, Credential, AuthSnapshot } from "./storage";
|
|
11
14
|
export {
|
|
12
15
|
OAUTH_FLOWS,
|
|
13
16
|
openInBrowser,
|
package/src/auth/oauth.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AuthProvider } from "./storage";
|
|
1
|
+
import type { AuthProvider, OAuthProvider } from "./storage";
|
|
2
2
|
import { setOauthToken, clearOauthToken, setOauthCredential } from "./storage";
|
|
3
3
|
import { OAUTH_FLOW_REGISTRY } from "./flows";
|
|
4
4
|
import type { OAuthController } from "./types";
|
|
@@ -10,7 +10,7 @@ export interface OauthFlowDef {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** Metadata kept for help text / manual-paste fallback. */
|
|
13
|
-
export const OAUTH_FLOWS: Record<
|
|
13
|
+
export const OAUTH_FLOWS: Record<OAuthProvider, OauthFlowDef> = {
|
|
14
14
|
anthropic: {
|
|
15
15
|
label: "Anthropic Console (Claude)",
|
|
16
16
|
authorizeUrl: "https://claude.ai/oauth/authorize",
|
|
@@ -64,7 +64,7 @@ export async function openInBrowser(url: string): Promise<void> {
|
|
|
64
64
|
* the local callback server, wait for the code (or manual paste), exchange it,
|
|
65
65
|
* and persist the full credential set (access + refresh + expiry).
|
|
66
66
|
*/
|
|
67
|
-
export async function interactiveLogin(provider:
|
|
67
|
+
export async function interactiveLogin(provider: OAuthProvider, ctrl: OAuthController): Promise<{ email?: string }> {
|
|
68
68
|
const flow = OAUTH_FLOW_REGISTRY[provider];
|
|
69
69
|
const creds = await flow.login(ctrl);
|
|
70
70
|
await setOauthCredential(provider, {
|
package/src/auth/refresh.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
acquireLock,
|
|
8
8
|
releaseLock,
|
|
9
9
|
type AuthProvider,
|
|
10
|
+
isOAuthProvider,
|
|
10
11
|
type Credential,
|
|
11
12
|
} from "./storage";
|
|
12
13
|
import { OAUTH_FLOW_REGISTRY } from "./flows";
|
|
@@ -24,6 +25,10 @@ export interface RefreshResult {
|
|
|
24
25
|
* Mirrors gjc's auth-broker refresher semantics (single source of truth).
|
|
25
26
|
*/
|
|
26
27
|
export async function refreshOAuthToken(provider: AuthProvider): Promise<RefreshResult> {
|
|
28
|
+
// API-key-only providers (xai/kimi) have no OAuth flow — nothing to refresh.
|
|
29
|
+
if (!isOAuthProvider(provider)) {
|
|
30
|
+
return { refreshed: false, reason: "no_oauth_token", credential: await resolveCredential(provider) };
|
|
31
|
+
}
|
|
27
32
|
await acquireLock(provider);
|
|
28
33
|
try {
|
|
29
34
|
const stored = await getStoredOAuth(provider);
|
package/src/auth/storage.ts
CHANGED
|
@@ -5,7 +5,18 @@ import { readGlobalConfig, readRawGlobalConfig, saveConfigPatch, type StoredOAut
|
|
|
5
5
|
import { jeoEnv } from "../util/env";
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** Providers with an interactive OAuth login + refresh flow. */
|
|
9
|
+
export type OAuthProvider = "anthropic" | "openai" | "gemini" | "antigravity";
|
|
10
|
+
/** Every provider jeo resolves a credential for: OAuth-capable ∪ API-key-only. */
|
|
11
|
+
export type AuthProvider = OAuthProvider | "xai" | "kimi" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax";
|
|
12
|
+
|
|
13
|
+
export const OAUTH_PROVIDERS: readonly OAuthProvider[] = ["anthropic", "openai", "gemini", "antigravity"];
|
|
14
|
+
/** API-key-only providers (no OAuth flow) — resolved from config.providers / `<NAME>_API_KEY`. */
|
|
15
|
+
export const API_KEY_ONLY_PROVIDERS: readonly AuthProvider[] = ["xai", "kimi", "groq", "deepseek", "mistral", "openrouter", "together", "cerebras", "fireworks", "nvidia", "alibaba-coding-plan", "huggingface", "nanogpt", "qwen-portal", "synthetic", "venice", "zenmux", "qianfan", "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", "minimax-code", "minimax-code-cn", "zai", "minimax"];
|
|
16
|
+
/** Narrow an AuthProvider to the OAuth-capable subset (xai/kimi have no OAuth flow). */
|
|
17
|
+
export function isOAuthProvider(p: AuthProvider): p is OAuthProvider {
|
|
18
|
+
return (OAUTH_PROVIDERS as readonly string[]).includes(p);
|
|
19
|
+
}
|
|
9
20
|
|
|
10
21
|
export type Credential =
|
|
11
22
|
| { kind: "oauth"; provider: AuthProvider; token: string; projectId?: string }
|
package/src/commands/auth.ts
CHANGED
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
logoutOAuth,
|
|
15
15
|
refreshOAuthToken,
|
|
16
16
|
snapshotProvider,
|
|
17
|
+
setApiKey,
|
|
18
|
+
isOAuthProvider,
|
|
19
|
+
OAUTH_PROVIDERS,
|
|
20
|
+
API_KEY_ONLY_PROVIDERS,
|
|
17
21
|
type AuthProvider,
|
|
18
22
|
type OAuthController,
|
|
19
23
|
} from "../auth";
|
|
@@ -29,7 +33,8 @@ export async function runAuthCommand(args: string[]): Promise<void> {
|
|
|
29
33
|
process.exitCode = 1;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
// Every loginable provider: OAuth-capable ∪ API-key-only (keyless ollama/lmstudio excluded).
|
|
37
|
+
const CLOUD_PROVIDERS: readonly AuthProvider[] = [...OAUTH_PROVIDERS, ...API_KEY_ONLY_PROVIDERS];
|
|
33
38
|
/** True (and prints an error + sets exit code) when `p` is given but not a known provider. */
|
|
34
39
|
function rejectInvalidProvider(p: string | undefined): boolean {
|
|
35
40
|
if (p !== undefined && !(CLOUD_PROVIDERS as readonly string[]).includes(p)) {
|
|
@@ -53,7 +58,7 @@ async function runAuthStatus(): Promise<void> {
|
|
|
53
58
|
const cfg = await readGlobalConfig();
|
|
54
59
|
console.log("\n=== jeo auth status ===");
|
|
55
60
|
console.log("Provider API key OAuth");
|
|
56
|
-
for (const p of
|
|
61
|
+
for (const p of CLOUD_PROVIDERS) {
|
|
57
62
|
const snap = await snapshotProvider(p);
|
|
58
63
|
const key = p === "antigravity" ? "—" : (snap.apiKey ? "set" : "—");
|
|
59
64
|
let oauth = "—";
|
|
@@ -99,6 +104,17 @@ async function runAuthLogin(rest: string[]): Promise<void> {
|
|
|
99
104
|
rl.close();
|
|
100
105
|
return;
|
|
101
106
|
}
|
|
107
|
+
// API-key-only providers (xai/kimi): no OAuth flow — store/guide an API key.
|
|
108
|
+
if ((API_KEY_ONLY_PROVIDERS as readonly string[]).includes(chosen)) {
|
|
109
|
+
rl.close();
|
|
110
|
+
if (manualToken) {
|
|
111
|
+
await setApiKey(chosen, manualToken.trim());
|
|
112
|
+
console.log(`[SUCCESS] Stored ${chosen.toUpperCase()}_API_KEY in ~/.jeo/config.json.`);
|
|
113
|
+
} else {
|
|
114
|
+
console.log(`Provider '${chosen}' is API-key only (no OAuth flow). Set ${chosen.toUpperCase()}_API_KEY, or run 'jeo auth login ${chosen} --token <key>'.`);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
102
118
|
|
|
103
119
|
// Non-interactive paste path (`--token`): store as a manual bearer.
|
|
104
120
|
if (manualToken) {
|
|
@@ -213,6 +229,9 @@ export async function interactiveOAuthLogin(
|
|
|
213
229
|
prompt: OAuthPrompt,
|
|
214
230
|
log: (s: string) => void = console.log,
|
|
215
231
|
): Promise<{ email?: string }> {
|
|
232
|
+
if (!isOAuthProvider(provider)) {
|
|
233
|
+
throw new Error(`Provider '${provider}' is API-key only (no OAuth flow) — set ${provider.toUpperCase()}_API_KEY or run 'jeo auth login ${provider} --token <key>'.`);
|
|
234
|
+
}
|
|
216
235
|
const flow = OAUTH_FLOW_REGISTRY[provider];
|
|
217
236
|
log(`\n=== OAuth login — ${flow.label} ===`);
|
|
218
237
|
if (!flow.verifiedEndToEnd && flow.note) log(`Note: ${flow.note}`);
|
|
@@ -51,7 +51,11 @@ export function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefin
|
|
|
51
51
|
const supported = catalogMetadata(modelId)?.thinking ?? [];
|
|
52
52
|
if (supported.includes("minimal")) return "minimal";
|
|
53
53
|
if (supported.includes("low")) return "low";
|
|
54
|
-
|
|
54
|
+
// Digit-count agnostic (gemini-10+ / 2.6+ stay reasoning) — mirrors the gates in
|
|
55
|
+
// gemini.ts and inferCatalogMetadata. Last resort for prefixed ids (models/gemini-…)
|
|
56
|
+
// the catalog lookup above misses; catalogued ids already returned via thinking caps.
|
|
57
|
+
const g = modelId.toLowerCase().match(/gemini-(\d+)(?:\.(\d+))?/);
|
|
58
|
+
if (g && (Number(g[1]) >= 3 || (Number(g[1]) === 2 && Number(g[2] ?? 0) >= 5))) return "minimal";
|
|
55
59
|
return undefined;
|
|
56
60
|
}
|
|
57
61
|
|
|
@@ -395,3 +395,16 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
|
|
|
395
395
|
},
|
|
396
396
|
};
|
|
397
397
|
}
|
|
398
|
+
|
|
399
|
+
/** Classify a mid-turn Enter draft. `/` (slash command) and `$` (skill) are jeo's
|
|
400
|
+
* command sigils: such a line must run as a COMMAND, never be steered as literal text
|
|
401
|
+
* into the running model. Anything else is a STEER query fed to the live turn; blank
|
|
402
|
+
* is EMPTY (ignored). Pure + exported so the live-turn handler and tests can't drift. */
|
|
403
|
+
export function classifyMidTurnLine(line: string): "command" | "steer" | "empty" {
|
|
404
|
+
const t = line.trim();
|
|
405
|
+
if (!t) return "empty";
|
|
406
|
+
// A lone sigil ("/" or "$") has no command name — ignore it instead of aborting the
|
|
407
|
+
// running turn to dispatch an empty command (a stray slash should not interrupt work).
|
|
408
|
+
if (t === "/" || t === "$") return "empty";
|
|
409
|
+
return /^[/$]/.test(t) ? "command" : "steer";
|
|
410
|
+
}
|