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.
Files changed (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.ja.md +6 -2
  3. package/README.ko.md +6 -2
  4. package/README.md +6 -2
  5. package/README.zh.md +6 -2
  6. package/package.json +1 -1
  7. package/src/agent/config-schema.ts +12 -0
  8. package/src/agent/session.ts +10 -3
  9. package/src/agent/state.ts +19 -14
  10. package/src/ai/index.ts +1 -0
  11. package/src/ai/model-catalog.ts +121 -1
  12. package/src/ai/model-discovery.ts +55 -3
  13. package/src/ai/model-manager.ts +43 -11
  14. package/src/ai/model-registry.ts +2 -0
  15. package/src/ai/provider-status.ts +45 -7
  16. package/src/ai/providers/anthropic-compatible.ts +27 -0
  17. package/src/ai/providers/anthropic.ts +3 -1
  18. package/src/ai/providers/antigravity.ts +31 -6
  19. package/src/ai/providers/gemini.ts +45 -4
  20. package/src/ai/providers/kimi.ts +18 -0
  21. package/src/ai/providers/lmstudio.ts +8 -0
  22. package/src/ai/providers/ollama.ts +17 -5
  23. package/src/ai/providers/openai-compatible-catalog.ts +83 -0
  24. package/src/ai/providers/openai-compatible.ts +34 -0
  25. package/src/ai/providers/openai-responses.ts +11 -0
  26. package/src/ai/providers/openai.ts +115 -7
  27. package/src/ai/providers/xai.ts +18 -0
  28. package/src/ai/register-providers.ts +18 -0
  29. package/src/ai/think-tags.ts +84 -0
  30. package/src/ai/types.ts +11 -1
  31. package/src/auth/flows/index.ts +3 -3
  32. package/src/auth/index.ts +4 -1
  33. package/src/auth/oauth.ts +3 -3
  34. package/src/auth/refresh.ts +5 -0
  35. package/src/auth/storage.ts +12 -1
  36. package/src/commands/auth.ts +21 -2
  37. package/src/commands/launch/flags.ts +5 -1
  38. package/src/commands/launch/input.ts +13 -0
  39. package/src/commands/launch.ts +307 -26
  40. package/src/commands/setup.ts +3 -2
  41. package/src/tui/app.ts +61 -41
  42. package/src/tui/components/ascii-art.ts +91 -124
  43. package/src/tui/components/autocomplete.ts +16 -0
  44. package/src/tui/components/forge.ts +1 -1
  45. package/src/tui/components/provider-picker.ts +162 -0
  46. package/src/tui/components/slash.ts +2 -2
  47. package/src/tui/components/transcript.ts +7 -0
  48. package/src/tui/components/welcome.ts +8 -8
  49. 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
- const isReasoning = /^o\d/.test(model) || /^gpt-5/.test(model);
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?: { content?: string; tool_calls?: { index?: number; function?: { name?: string; arguments?: string } }[] }; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
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 delta = chunk.choices?.[0]?.delta?.content;
114
- if (delta) {
115
- yieldedAny = true;
116
- yield delta;
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;
@@ -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: AuthProvider;
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<AuthProvider, OAuthFlow> = {
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<AuthProvider, OauthFlowDef> = {
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: AuthProvider, ctrl: OAuthController): Promise<{ email?: string }> {
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, {
@@ -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);
@@ -5,7 +5,18 @@ import { readGlobalConfig, readRawGlobalConfig, saveConfigPatch, type StoredOAut
5
5
  import { jeoEnv } from "../util/env";
6
6
 
7
7
 
8
- export type AuthProvider = "anthropic" | "openai" | "gemini" | "antigravity";
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 }
@@ -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
- const CLOUD_PROVIDERS: readonly AuthProvider[] = ["anthropic", "openai", "gemini", "antigravity"]
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 ["anthropic", "openai", "gemini", "antigravity"] as AuthProvider[]) {
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
- if (/gemini-(2\.5|[3-9])/.test(modelId.toLowerCase())) return "minimal";
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
+ }