@giovannijecha/jecode 0.8.4 → 0.8.6

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 (99) hide show
  1. package/README.md +28 -9
  2. package/dist/accounts.js +47 -10
  3. package/dist/atomic.js +24 -14
  4. package/dist/batch-view.js +27 -2
  5. package/dist/batch.js +39 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/cli-info.js +0 -1
  8. package/dist/commands.js +2 -0
  9. package/dist/config.js +16 -7
  10. package/dist/context/automatic.js +35 -0
  11. package/dist/context/compactor.js +32 -2
  12. package/dist/context/manual.js +20 -3
  13. package/dist/context/request-projection.js +130 -0
  14. package/dist/controller-request.js +33 -11
  15. package/dist/credential-commands.js +25 -9
  16. package/dist/credentials.js +56 -18
  17. package/dist/directory-anchor.js +91 -0
  18. package/dist/file-identity.js +12 -0
  19. package/dist/model-command.js +5 -4
  20. package/dist/openai-account-command.js +23 -10
  21. package/dist/openai-account.js +7 -5
  22. package/dist/openai-oauth-callback.js +13 -4
  23. package/dist/openai-oauth-tokens.js +9 -7
  24. package/dist/openai-oauth.js +8 -6
  25. package/dist/permission-command.js +1 -1
  26. package/dist/process-lease.js +329 -0
  27. package/dist/provider-commands.js +11 -37
  28. package/dist/provider-errors.js +31 -7
  29. package/dist/provider-label.js +9 -3
  30. package/dist/providers/anthropic-stream.js +4 -1
  31. package/dist/providers/anthropic-wire.js +8 -3
  32. package/dist/providers/anthropic.js +39 -20
  33. package/dist/providers/catalog.js +4 -4
  34. package/dist/providers/failure.js +181 -0
  35. package/dist/providers/http.js +82 -23
  36. package/dist/providers/index.js +1 -5
  37. package/dist/providers/ollama-context.js +42 -0
  38. package/dist/providers/ollama-endpoint.js +7 -34
  39. package/dist/providers/ollama-stream.js +5 -1
  40. package/dist/providers/ollama.js +36 -158
  41. package/dist/providers/openai-codex.js +71 -45
  42. package/dist/providers/openai-stream.js +26 -2
  43. package/dist/providers/openai.js +51 -25
  44. package/dist/providers/sse.js +52 -8
  45. package/dist/request-identity.js +32 -0
  46. package/dist/sessions/bucket.js +55 -0
  47. package/dist/sessions/catalog-io.js +162 -0
  48. package/dist/sessions/catalog.js +3 -1
  49. package/dist/sessions/codec-messages.js +122 -0
  50. package/dist/sessions/codec-transcript.js +93 -0
  51. package/dist/sessions/codec-values.js +52 -0
  52. package/dist/sessions/codec.js +4 -257
  53. package/dist/sessions/files.js +158 -0
  54. package/dist/sessions/lease.js +132 -49
  55. package/dist/sessions/load.js +90 -0
  56. package/dist/sessions/runtime.js +15 -8
  57. package/dist/sessions/snapshot.js +33 -0
  58. package/dist/sessions/store.js +183 -378
  59. package/dist/settings-command.js +10 -5
  60. package/dist/settings.js +75 -22
  61. package/dist/stable-directory.js +148 -0
  62. package/dist/start.js +1 -2
  63. package/dist/store-lock.js +68 -84
  64. package/dist/tools/args.js +2 -2
  65. package/dist/tools/file-read.js +192 -0
  66. package/dist/tools/file-summary.js +9 -0
  67. package/dist/tools/{fs.js → file-write.js} +5 -171
  68. package/dist/tools/glob.js +107 -0
  69. package/dist/tools/index.js +2 -1
  70. package/dist/tools/search.js +66 -205
  71. package/dist/tools/text-boundary.js +7 -33
  72. package/dist/tui/app-workflows.js +9 -334
  73. package/dist/tui/approve.js +5 -3
  74. package/dist/tui/blocks.js +8 -7
  75. package/dist/tui/command-workflow.js +106 -0
  76. package/dist/tui/components/command-menu.js +7 -10
  77. package/dist/tui/components/footer.js +1 -1
  78. package/dist/tui/components/menu.js +74 -43
  79. package/dist/tui/components/messages.js +16 -9
  80. package/dist/tui/components/tool-evidence.js +107 -0
  81. package/dist/tui/components/tool-motion.js +32 -0
  82. package/dist/tui/components/tool.js +48 -202
  83. package/dist/tui/feedback.js +4 -0
  84. package/dist/tui/help.js +1 -1
  85. package/dist/tui/picker-layout.js +40 -0
  86. package/dist/tui/picker.js +7 -71
  87. package/dist/tui/session-view.js +7 -2
  88. package/dist/tui/tool-details.js +135 -0
  89. package/dist/tui/transcript-grammar.js +8 -1
  90. package/dist/tui/transcript-view.js +26 -112
  91. package/dist/tui/turn-workflow.js +264 -0
  92. package/dist/tui/turn.js +7 -140
  93. package/dist/tui/workflow-types.js +2 -0
  94. package/dist/tui/workspace.js +21 -7
  95. package/dist/user-store.js +23 -31
  96. package/package.json +14 -15
  97. package/dist/ollama-settings-command.js +0 -74
  98. package/dist/tools/ripgrep.js +0 -230
  99. package/dist/tui/motion.js +0 -32
@@ -1,186 +1,64 @@
1
- // Ollama, spoken through its OpenAI-compatible Chat Completions endpoint.
2
- //
3
- // One provider covers both deployments. An explicit session endpoint wins;
4
- // otherwise a configured key selects Ollama Cloud and no key selects the local
5
- // daemon. There is no default model — the catalogue is whatever the host has
6
- // pulled or the subscription grants — so the model has to be named with
7
- // --model.
1
+ // Ollama Cloud through its OpenAI-compatible streaming Chat Completions API.
8
2
  import { requireSupportedEffort } from "../effort.js";
9
- import { getJson, postJson, postSse } from "./http.js";
3
+ import { postSse } from "./http.js";
10
4
  import { listModels } from "./catalog.js";
11
5
  import { keyFor } from "../credentials.js";
12
6
  import { assembleOllama } from "./ollama-stream.js";
13
- import { OLLAMA_CLOUD_HOST, OLLAMA_LOCAL_HOST, ollamaConnectionKind, parseOllamaEndpoint, } from "./ollama-endpoint.js";
7
+ import { ollamaContextWindow } from "./ollama-context.js";
8
+ import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
9
+ import { OLLAMA_CLOUD_HOST } from "./ollama-endpoint.js";
14
10
  import { fromWireReply, stopNotice, toWireMessages, toWireTool } from "./ollama-wire.js";
15
11
  const KEY = "OLLAMA_API_KEY";
12
+ const ID = "ollama";
16
13
  // Ollama also accepts `none`; Jecode's product-wide reasoning floor is `low`.
17
14
  const OLLAMA_EFFORTS = ["low", "medium", "high"];
18
- let configuredHost;
19
- const CONTEXT_CACHE_MS = 30_000;
20
- const runtimeContextByEndpoint = new Map();
21
- const modelContextByEndpoint = new Map();
22
- /** Set the endpoint selected for this process. Undefined restores key-aware inference. */
23
- export function configureOllama(host) {
24
- configuredHost = host === undefined ? undefined : parseOllamaEndpoint(host).baseUrl;
25
- }
26
- export function ollamaConnection() {
27
- const inferred = configuredHost === undefined;
28
- const endpoint = parseOllamaEndpoint(configuredHost ?? (apiKey() === undefined ? OLLAMA_LOCAL_HOST : OLLAMA_CLOUD_HOST));
29
- return { ...endpoint, kind: ollamaConnectionKind(endpoint), inferred };
30
- }
31
15
  export const ollama = {
32
- id: "ollama",
16
+ id: ID,
33
17
  defaultModel: "",
34
18
  auth: { kind: "api-key", keyVar: KEY },
35
- // The only provider whose key is conditional: a daemon on this machine is
36
- // reached over loopback and asks for nothing, so demanding a key there
37
- // would be an invented requirement.
38
19
  blocked() {
20
+ return keyFor(KEY) === undefined ? `${KEY} is not set` : undefined;
21
+ },
22
+ async models(signal, onStatus) {
39
23
  try {
40
- const at = endpoint();
41
- if (apiKey() !== undefined || at.loopback)
42
- return undefined;
43
- return `${KEY} is not set (required by ${at.baseUrl})`;
24
+ return await listModels(`${OLLAMA_CLOUD_HOST}/v1/models`, headers(), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
44
25
  }
45
26
  catch (error) {
46
- return error.message;
27
+ throwProviderError(ID, signal, error);
47
28
  }
48
29
  },
49
- // Whatever the daemon has pulled, or whatever the subscription grants.
50
- models(signal, onStatus) {
51
- const at = endpoint();
52
- return listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus);
53
- },
54
30
  async efforts() {
55
31
  return OLLAMA_EFFORTS;
56
32
  },
57
33
  async contextWindow(model, signal, onStatus) {
58
- const at = endpoint();
59
- const cacheKey = `${at.baseUrl}\u0000${model}`;
60
- const runtime = cachedContext(runtimeContextByEndpoint, cacheKey);
61
- if (runtime !== undefined)
62
- return runtime;
63
- const modelCapacity = cachedContext(modelContextByEndpoint, cacheKey);
64
- const observed = await nativeContextWindow(at, model, modelCapacity, signal, onStatus);
65
- if (observed?.runtime === true) {
66
- rememberContext(runtimeContextByEndpoint, cacheKey, observed.value);
67
- }
68
- else if (observed !== undefined && modelCapacity === undefined) {
69
- rememberContext(modelContextByEndpoint, cacheKey, observed.value);
70
- }
71
- return observed?.value;
34
+ return ollamaContextWindow(model, headers(), signal, onStatus);
72
35
  },
73
- location: () => {
36
+ async send(req) {
37
+ const effort = requireSupportedEffort(req.model, req.effort, OLLAMA_EFFORTS);
74
38
  try {
75
- return endpoint().loopback ? "local" : "cloud";
39
+ const events = await postSse(`${OLLAMA_CLOUD_HOST}/v1/chat/completions`, headers(), {
40
+ model: req.model,
41
+ messages: toWireMessages(req.system, req.messages),
42
+ tools: req.tools.map(toWireTool),
43
+ max_tokens: req.maxTokens,
44
+ reasoning_effort: effort,
45
+ stream: true,
46
+ stream_options: { include_usage: true },
47
+ }, req.maxTokens, req.signal, req.onStatus, undefined, (error) => isRetryableGenerationFailure(ID, error));
48
+ const reply = await assembleOllama(events, req.onStream);
49
+ const notice = stopNotice(reply);
50
+ if (notice !== undefined)
51
+ req.onStream?.({ kind: "text", text: `\n${notice}` });
52
+ return fromWireReply(reply);
76
53
  }
77
- catch {
78
- return "cloud";
54
+ catch (error) {
55
+ throwProviderError(ID, req.signal, error);
79
56
  }
80
57
  },
81
- async send(req) {
82
- const at = endpoint();
83
- const effort = requireSupportedEffort(req.model, req.effort, OLLAMA_EFFORTS);
84
- // The OpenAI-compatible endpoint accepts this vocabulary for thinking
85
- // models. Invalid levels are rejected locally instead of being rewritten.
86
- const events = await postSse(`${at.baseUrl}/v1/chat/completions`, headers(at), {
87
- model: req.model,
88
- messages: toWireMessages(req.system, req.messages),
89
- tools: req.tools.map(toWireTool),
90
- max_tokens: req.maxTokens,
91
- reasoning_effort: effort,
92
- stream: true,
93
- stream_options: { include_usage: true },
94
- }, req.maxTokens, req.signal, req.onStatus);
95
- const reply = await assembleOllama(events, req.onStream);
96
- const notice = stopNotice(reply);
97
- if (notice !== undefined)
98
- req.onStream?.({ kind: "text", text: `\n${notice}` });
99
- return fromWireReply(reply);
100
- },
101
58
  };
102
- async function nativeContextWindow(at, model, fallback, signal, onStatus) {
103
- try {
104
- const running = await getJson(`${at.baseUrl}/api/ps`, headers(at), signal, onStatus);
105
- const allocated = runningContext(running, model);
106
- if (allocated !== undefined)
107
- return { value: usableContext(allocated), runtime: true };
108
- }
109
- catch (error) {
110
- throwIfAborted(signal, error);
111
- }
112
- if (fallback !== undefined)
113
- return { value: fallback, runtime: false };
114
- try {
115
- const details = await postJson(`${at.baseUrl}/api/show`, headers(at), { model }, signal, onStatus);
116
- const capacity = modelCapacity(details);
117
- return capacity === undefined
118
- ? undefined
119
- : { value: usableContext(capacity), runtime: false };
120
- }
121
- catch (error) {
122
- throwIfAborted(signal, error);
123
- return undefined;
124
- }
125
- }
126
- function cachedContext(cache, key) {
127
- const cached = cache.get(key);
128
- if (cached === undefined)
129
- return undefined;
130
- if (cached.expiresAt > Date.now())
131
- return cached.value;
132
- cache.delete(key);
133
- return undefined;
134
- }
135
- function rememberContext(cache, key, value) {
136
- cache.set(key, { value, expiresAt: Date.now() + CONTEXT_CACHE_MS });
137
- }
138
- function runningContext(value, model) {
139
- if (!record(value) || !Array.isArray(value["models"]))
140
- return undefined;
141
- for (const entry of value["models"]) {
142
- if (!record(entry))
143
- continue;
144
- if (entry["name"] !== model && entry["model"] !== model)
145
- continue;
146
- if (validTokenCount(entry["context_length"]))
147
- return entry["context_length"];
148
- }
149
- return undefined;
150
- }
151
- function modelCapacity(value) {
152
- if (!record(value) || !record(value["model_info"]))
153
- return undefined;
154
- const capacities = Object.entries(value["model_info"])
155
- .filter(([name, count]) => name.endsWith(".context_length") && validTokenCount(count))
156
- .map(([, count]) => count);
157
- return capacities.length === 0 ? undefined : Math.max(...capacities);
158
- }
159
- function validTokenCount(value) {
160
- return typeof value === "number" && Number.isSafeInteger(value) &&
161
- value >= 4_096 && value <= 10_000_000;
162
- }
163
- function usableContext(tokens) {
164
- return Object.freeze({ tokens: Math.floor(tokens * 95 / 100) });
165
- }
166
- function record(value) {
167
- return typeof value === "object" && value !== null && !Array.isArray(value);
168
- }
169
- function throwIfAborted(signal, error) {
170
- if (signal?.aborted === true)
171
- throw error;
172
- }
173
- function endpoint() {
174
- return ollamaConnection();
175
- }
176
- function apiKey() {
177
- return keyFor(KEY);
178
- }
179
- function headers(at) {
180
- if (at.loopback)
181
- return {};
182
- const key = apiKey();
183
- if (key !== undefined)
184
- return { authorization: `Bearer ${key}` };
185
- throw new Error(`${KEY} is not set (required by ${at.baseUrl})`);
59
+ function headers() {
60
+ const key = keyFor(KEY);
61
+ if (key === undefined)
62
+ throw new Error(`${KEY} is not set`);
63
+ return { authorization: `Bearer ${key}` };
186
64
  }
@@ -2,10 +2,12 @@
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { openAICodexAccount } from "../accounts.js";
4
4
  import { openAIAuthorization } from "../openai-account.js";
5
+ import { providerLabel } from "../provider-label.js";
5
6
  import { applicationVersion } from "../version.js";
6
7
  import { EFFORTS, isEffort, requireSupportedEffort } from "../effort.js";
8
+ import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
7
9
  import { getJson, postSse } from "./http.js";
8
- import { assembleOpenAI } from "./openai-stream.js";
10
+ import { assembleOpenAI, openAIStreamProgress } from "./openai-stream.js";
9
11
  import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
10
12
  const ID = "openai-codex";
11
13
  const BASE = "https://chatgpt.com/backend-api/codex";
@@ -13,7 +15,6 @@ const BASE = "https://chatgpt.com/backend-api/codex";
13
15
  // own catalogue updater uses this sentinel to request the complete current
14
16
  // manifest; Jecode then keeps only entries explicitly visible in that manifest.
15
17
  const CATALOG_COMPATIBILITY_VERSION = "99.99.99";
16
- const SESSION_ID = randomUUID();
17
18
  const MAX_CATALOG_ITEMS = 4_000;
18
19
  const MAX_MODELS = 1_000;
19
20
  const MAX_MODEL_CHARS = 256;
@@ -23,66 +24,89 @@ let contextByModel = new Map();
23
24
  export const openaiCodex = {
24
25
  id: ID,
25
26
  defaultModel: "",
26
- auth: { kind: "oauth", account: ID, label: "ChatGPT" },
27
+ auth: { kind: "oauth", account: ID, label: providerLabel(ID) },
27
28
  blocked() {
28
- return openAICodexAccount() === undefined ? "ChatGPT account is not connected" : undefined;
29
+ return openAICodexAccount() === undefined ? `${providerLabel(ID)} is not connected` : undefined;
29
30
  },
30
- location: () => "cloud",
31
31
  async models(signal, onStatus) {
32
- const catalog = await loadCatalog(signal, onStatus);
33
- rememberCatalog(catalog);
34
- return catalog.ids;
32
+ try {
33
+ const catalog = await loadCatalog(signal, onStatus);
34
+ rememberCatalog(catalog);
35
+ return catalog.ids;
36
+ }
37
+ catch (error) {
38
+ throwProviderError(ID, signal, error);
39
+ }
35
40
  },
36
41
  async efforts(model, signal, onStatus) {
37
42
  const cached = effortByModel.get(model);
38
43
  if (cached !== undefined)
39
44
  return cached;
40
- const catalog = await loadCatalog(signal, onStatus);
41
- rememberCatalog(catalog);
42
- return effortByModel.get(model) ?? fallbackEfforts(model);
45
+ try {
46
+ const catalog = await loadCatalog(signal, onStatus);
47
+ rememberCatalog(catalog);
48
+ return effortByModel.get(model) ?? fallbackEfforts(model);
49
+ }
50
+ catch (error) {
51
+ throwProviderError(ID, signal, error);
52
+ }
43
53
  },
44
54
  async contextWindow(model, signal, onStatus) {
45
55
  if (contextByModel.has(model))
46
56
  return contextByModel.get(model);
47
- const catalog = await loadCatalog(signal, onStatus);
48
- rememberCatalog(catalog);
49
- const context = contextByModel.get(model);
50
- if (!contextByModel.has(model))
51
- contextByModel.set(model, undefined);
52
- return context;
57
+ try {
58
+ const catalog = await loadCatalog(signal, onStatus);
59
+ rememberCatalog(catalog);
60
+ const context = contextByModel.get(model);
61
+ if (!contextByModel.has(model))
62
+ contextByModel.set(model, undefined);
63
+ return context;
64
+ }
65
+ catch (error) {
66
+ throwProviderError(ID, signal, error);
67
+ }
53
68
  },
54
69
  async send(req) {
55
70
  const efforts = effortByModel.get(req.model) ?? fallbackEfforts(req.model);
56
71
  const effort = requireSupportedEffort(req.model, req.effort, efforts);
57
- return withAuthorization(async (authorization) => {
58
- const events = await postSse(`${BASE}/responses`, {
59
- ...headers(authorization, randomUUID()),
60
- "openai-beta": "responses=experimental",
61
- }, {
62
- model: req.model,
63
- store: false,
64
- stream: true,
65
- instructions: req.system,
66
- input: req.messages.flatMap((message) => toWireItems(message, ID)),
67
- tools: req.tools.map(toWireTool),
68
- tool_choice: "auto",
69
- parallel_tool_calls: true,
70
- reasoning: { effort, summary: "auto" },
71
- text: { verbosity: "low" },
72
- include: ["reasoning.encrypted_content"],
73
- prompt_cache_key: SESSION_ID,
74
- }, req.maxTokens, req.signal, req.onStatus);
75
- const data = await assembleOpenAI(events, req.onStream, req.onStatus);
76
- const notice = stopNotice(data);
77
- if (notice !== undefined)
78
- req.onStream?.({ kind: "text", text: `\n${notice}` });
79
- return fromWireResponse(data, ID);
80
- }, req.signal, req.onStatus);
72
+ const sessionId = req.identity?.conversationId ?? randomUUID();
73
+ const cacheKey = req.identity?.cacheKey ?? sessionId;
74
+ try {
75
+ return await withAuthorization(async (authorization) => {
76
+ const events = await postSse(`${BASE}/responses`, {
77
+ ...headers(authorization, sessionId, randomUUID()),
78
+ "openai-beta": "responses=experimental",
79
+ }, {
80
+ model: req.model,
81
+ store: false,
82
+ stream: true,
83
+ instructions: req.system,
84
+ input: req.messages.flatMap((message) => toWireItems(message, ID)),
85
+ tools: req.tools.map(toWireTool),
86
+ tool_choice: "auto",
87
+ parallel_tool_calls: true,
88
+ reasoning: { effort, summary: "auto" },
89
+ text: { verbosity: "low" },
90
+ include: ["reasoning.encrypted_content"],
91
+ ...(req.identity?.purpose === "compaction"
92
+ ? {}
93
+ : { prompt_cache_key: cacheKey }),
94
+ }, req.maxTokens, req.signal, req.onStatus, openAIStreamProgress, (error) => isRetryableGenerationFailure(ID, error));
95
+ const data = await assembleOpenAI(events, req.onStream, req.onStatus);
96
+ const notice = stopNotice(data);
97
+ if (notice !== undefined)
98
+ req.onStream?.({ kind: "text", text: `\n${notice}` });
99
+ return fromWireResponse(data, ID);
100
+ }, req.signal, req.onStatus);
101
+ }
102
+ catch (error) {
103
+ throwProviderError(ID, req.signal, error);
104
+ }
81
105
  },
82
106
  };
83
107
  async function loadCatalog(signal, onStatus) {
84
108
  return withAuthorization(async (authorization) => {
85
- const body = await getJson(`${BASE}/models?client_version=${CATALOG_COMPATIBILITY_VERSION}`, headers(authorization, randomUUID()), signal, onStatus);
109
+ const body = await getJson(`${BASE}/models?client_version=${CATALOG_COMPATIBILITY_VERSION}`, headers(authorization, randomUUID(), randomUUID()), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
86
110
  return modelCatalog(body);
87
111
  }, signal, onStatus);
88
112
  }
@@ -102,21 +126,21 @@ async function withAuthorization(operation, signal, onStatus) {
102
126
  return operation(authorization);
103
127
  }
104
128
  }
105
- function headers(authorization, requestId) {
129
+ function headers(authorization, sessionId, requestId) {
106
130
  const version = applicationVersion();
107
131
  return {
108
132
  authorization: `Bearer ${authorization.accessToken}`,
109
133
  "chatgpt-account-id": authorization.accountId,
110
134
  originator: "jecode",
111
135
  "user-agent": `jecode/${version} (${process.platform}; ${process.arch})`,
112
- "session-id": SESSION_ID,
136
+ "session-id": sessionId,
113
137
  "x-client-request-id": requestId,
114
138
  };
115
139
  }
116
140
  function modelCatalog(value) {
117
141
  const source = record(value) && Array.isArray(value["models"]) ? value["models"] : undefined;
118
142
  if (source === undefined)
119
- throw new Error("OpenAI Codex did not return a model list");
143
+ throw new Error(`${providerLabel(ID)} did not return a model list`);
120
144
  const seen = new Set();
121
145
  const models = source
122
146
  .slice(0, MAX_CATALOG_ITEMS)
@@ -185,6 +209,8 @@ function reasoningLevels(entry, model) {
185
209
  return efforts.length === 0 ? fallbackEfforts(model) : efforts;
186
210
  }
187
211
  function fallbackEfforts(model) {
212
+ if (/^gpt-6-astra(?:-|$)/.test(model))
213
+ return EFFORTS;
188
214
  if (/^gpt-5\.6-(?:sol|terra|luna)(?:-|$)/.test(model))
189
215
  return EFFORTS;
190
216
  return XHIGH_EFFORTS;
@@ -4,6 +4,7 @@
4
4
  // response in `response.completed`. The ChatGPT Codex backend can instead send
5
5
  // an empty final `output` after complete `response.output_item.done` events, so
6
6
  // those streamed items remain the fallback when the final envelope is empty.
7
+ import { providerWireError } from "./failure.js";
7
8
  export async function assembleOpenAI(events, onStream, onStatus) {
8
9
  const items = [];
9
10
  const announcedTools = { identities: new Set(), anonymous: false };
@@ -69,6 +70,9 @@ export async function assembleOpenAI(events, onStream, onStatus) {
69
70
  if (isFunctionCall(event.item)) {
70
71
  announceTool(event, event.item, announcedTools, onStream, status);
71
72
  }
73
+ else if (itemType(event.item) === "reasoning") {
74
+ status("Working");
75
+ }
72
76
  items.push(event.item);
73
77
  }
74
78
  break;
@@ -82,16 +86,36 @@ export async function assembleOpenAI(events, onStream, onStatus) {
82
86
  };
83
87
  case "response.failed": {
84
88
  const response = event.response;
85
- throw new Error(`openai stream error: ${response?.error?.message ?? "unspecified"}`);
89
+ throw providerWireError("openai stream error", response?.error?.message, {
90
+ code: response?.error?.code,
91
+ type: response?.error?.type,
92
+ });
86
93
  }
87
94
  case "error":
88
- throw new Error(`openai stream error: ${event.error?.message ?? event.message ?? "unspecified"}`);
95
+ throw providerWireError("openai stream error", event.error?.message ?? event.message, { code: event.error?.code, type: event.error?.type });
89
96
  default:
90
97
  break;
91
98
  }
92
99
  }
93
100
  throw new Error("openai stream ended before a terminal response event");
94
101
  }
102
+ /** State-only keepalives prove transport liveness, not forward model progress. */
103
+ export function openAIStreamProgress(raw) {
104
+ if (typeof raw !== "object" || raw === null)
105
+ return false;
106
+ const type = raw["type"];
107
+ if (typeof type !== "string")
108
+ return false;
109
+ if (type === "response.created")
110
+ return true;
111
+ if (type === "response.done" ||
112
+ type === "response.completed" ||
113
+ type === "response.incomplete" ||
114
+ type === "response.failed" ||
115
+ type === "error")
116
+ return true;
117
+ return /\.(?:added|delta|done)$/u.test(type);
118
+ }
95
119
  function isFunctionCall(item) {
96
120
  return typeof item === "object" && item !== null &&
97
121
  item["type"] === "function_call";
@@ -2,15 +2,20 @@
2
2
  //
3
3
  // Responses wire contract verified against the official API reference on
4
4
  // 2026-08-29. Keep final response events authoritative over display deltas.
5
+ import { randomUUID } from "node:crypto";
6
+ import { applicationVersion } from "../version.js";
5
7
  import { postSse } from "./http.js";
6
8
  import { listModels } from "./catalog.js";
7
9
  import { keyFor } from "../credentials.js";
8
10
  import { EFFORTS, requireSupportedEffort } from "../effort.js";
9
- import { assembleOpenAI } from "./openai-stream.js";
11
+ import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
12
+ import { assembleOpenAI, openAIStreamProgress } from "./openai-stream.js";
10
13
  import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
11
14
  const ENDPOINT = "https://api.openai.com/v1/responses";
12
15
  const MODELS = "https://api.openai.com/v1/models";
13
16
  const KEY = "OPENAI_API_KEY";
17
+ const ID = "openai";
18
+ const ASTRA_MODEL = /^gpt-6-astra(?:-|$)/;
14
19
  const RESPONSES_REASONING_MODEL = /^(?:gpt-5(?:[.-]|$)|o(?:1|3|4)(?:[.-]|$)|codex-mini(?:[.-]|$))/;
15
20
  // Jecode's transport always streams and always declares local tools. Hide
16
21
  // catalog entries that cannot satisfy either half of that contract.
@@ -20,11 +25,14 @@ const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
20
25
  const PRO_EFFORTS = ["medium", "high", "xhigh"];
21
26
  const HIGH_ONLY_EFFORT = ["high"];
22
27
  export function supportsOpenAIModel(model) {
23
- return RESPONSES_REASONING_MODEL.test(model) && !INCOMPATIBLE_MODEL.test(model);
28
+ return ASTRA_MODEL.test(model) ||
29
+ (RESPONSES_REASONING_MODEL.test(model) && !INCOMPATIBLE_MODEL.test(model));
24
30
  }
25
31
  export function openAIEfforts(model) {
26
32
  if (!supportsOpenAIModel(model))
27
33
  return [];
34
+ if (ASTRA_MODEL.test(model))
35
+ return EFFORTS;
28
36
  if (/^gpt-5-pro(?:-|$)/.test(model))
29
37
  return HIGH_ONLY_EFFORT;
30
38
  if (/^gpt-5\.[2-5]-pro(?:-|$)/.test(model))
@@ -39,6 +47,8 @@ export function openAIEfforts(model) {
39
47
  }
40
48
  /** Conservative capacities for the reasoning families accepted by this transport. */
41
49
  export function openAIContextWindow(model) {
50
+ if (ASTRA_MODEL.test(model))
51
+ return usableContext(1_050_000);
42
52
  if (/^gpt-5\.6(?:[.-]|$)/.test(model))
43
53
  return usableContext(1_050_000);
44
54
  if (/^gpt-5(?:[.-]|$)/.test(model))
@@ -52,7 +62,7 @@ function usableContext(tokens) {
52
62
  return Object.freeze({ tokens: Math.floor(tokens * 95 / 100) });
53
63
  }
54
64
  export const openai = {
55
- id: "openai",
65
+ id: ID,
56
66
  defaultModel: "gpt-5",
57
67
  auth: { kind: "api-key", keyVar: KEY },
58
68
  blocked() {
@@ -61,10 +71,15 @@ export const openai = {
61
71
  // The endpoint answers in no order worth keeping, so descending puts the
62
72
  // highest-numbered family — usually the newest — at the top of the menu.
63
73
  async models(signal, onStatus) {
64
- const ids = await listModels(MODELS, headers(requireKey()), signal, onStatus);
65
- return ids
66
- .filter(supportsOpenAIModel)
67
- .sort((a, b) => b.localeCompare(a));
74
+ try {
75
+ const ids = await listModels(MODELS, headers(requireKey()), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
76
+ return ids
77
+ .filter(supportsOpenAIModel)
78
+ .sort((a, b) => b.localeCompare(a));
79
+ }
80
+ catch (error) {
81
+ throwProviderError(ID, signal, error);
82
+ }
68
83
  },
69
84
  async efforts(model) {
70
85
  return openAIEfforts(model);
@@ -72,26 +87,33 @@ export const openai = {
72
87
  async contextWindow(model) {
73
88
  return openAIContextWindow(model);
74
89
  },
75
- location: () => "cloud",
76
90
  async send(req) {
77
91
  const key = requireKey();
78
92
  const effort = requireSupportedEffort(req.model, req.effort, openAIEfforts(req.model));
79
- const events = await postSse(ENDPOINT, headers(key), {
80
- model: req.model,
81
- instructions: req.system,
82
- input: req.messages.flatMap((message) => toWireItems(message)),
83
- tools: req.tools.map(toWireTool),
84
- max_output_tokens: req.maxTokens,
85
- reasoning: { effort, summary: "auto" },
86
- store: false,
87
- include: ["reasoning.encrypted_content"],
88
- stream: true,
89
- }, req.maxTokens, req.signal, req.onStatus);
90
- const data = await assembleOpenAI(events, req.onStream, req.onStatus);
91
- const notice = stopNotice(data);
92
- if (notice !== undefined)
93
- req.onStream?.({ kind: "text", text: `\n${notice}` });
94
- return fromWireResponse(data);
93
+ try {
94
+ const events = await postSse(ENDPOINT, headers(key), {
95
+ model: req.model,
96
+ instructions: req.system,
97
+ input: req.messages.flatMap((message) => toWireItems(message)),
98
+ tools: req.tools.map(toWireTool),
99
+ max_output_tokens: req.maxTokens,
100
+ reasoning: { effort, summary: "auto" },
101
+ store: false,
102
+ include: ["reasoning.encrypted_content"],
103
+ stream: true,
104
+ ...(req.identity?.purpose === "turn"
105
+ ? { prompt_cache_key: req.identity.cacheKey }
106
+ : {}),
107
+ }, req.maxTokens, req.signal, req.onStatus, openAIStreamProgress, (error) => isRetryableGenerationFailure(ID, error));
108
+ const data = await assembleOpenAI(events, req.onStream, req.onStatus);
109
+ const notice = stopNotice(data);
110
+ if (notice !== undefined)
111
+ req.onStream?.({ kind: "text", text: `\n${notice}` });
112
+ return fromWireResponse(data);
113
+ }
114
+ catch (error) {
115
+ throwProviderError(ID, req.signal, error);
116
+ }
95
117
  },
96
118
  };
97
119
  function apiKey() {
@@ -104,5 +126,9 @@ function requireKey() {
104
126
  return key;
105
127
  }
106
128
  function headers(key) {
107
- return { authorization: `Bearer ${key}` };
129
+ return {
130
+ authorization: `Bearer ${key}`,
131
+ "user-agent": `jecode/${applicationVersion()} (${process.platform}; ${process.arch})`,
132
+ "x-client-request-id": randomUUID(),
133
+ };
108
134
  }