@giovannijecha/jecode 0.1.9 → 0.2.1

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 (42) hide show
  1. package/README.md +36 -18
  2. package/dist/account-lock.js +112 -0
  3. package/dist/accounts.js +100 -0
  4. package/dist/cli-info.js +2 -2
  5. package/dist/commands.js +1 -1
  6. package/dist/controller.js +46 -11
  7. package/dist/credential-commands.js +32 -4
  8. package/dist/credential-safety.js +10 -1
  9. package/dist/effort.js +25 -0
  10. package/dist/external-browser.js +53 -0
  11. package/dist/oauth-http.js +114 -0
  12. package/dist/openai-account-command.js +124 -0
  13. package/dist/openai-account.js +94 -0
  14. package/dist/openai-oauth-callback.js +186 -0
  15. package/dist/openai-oauth-tokens.js +65 -0
  16. package/dist/openai-oauth.js +203 -0
  17. package/dist/provider-commands.js +79 -38
  18. package/dist/provider-label.js +10 -0
  19. package/dist/providers/anthropic-stream.js +8 -2
  20. package/dist/providers/anthropic.js +22 -6
  21. package/dist/providers/index.js +2 -1
  22. package/dist/providers/ollama-stream.js +3 -0
  23. package/dist/providers/ollama.js +4 -1
  24. package/dist/providers/openai-codex.js +157 -0
  25. package/dist/providers/openai-stream.js +13 -9
  26. package/dist/providers/openai-wire.js +4 -9
  27. package/dist/providers/openai.js +36 -15
  28. package/dist/providers/sse.js +1 -1
  29. package/dist/settings-command.js +64 -9
  30. package/dist/settings.js +2 -1
  31. package/dist/tools/args.js +4 -3
  32. package/dist/tools/fs.js +8 -3
  33. package/dist/tools/index.js +2 -2
  34. package/dist/tools/shell.js +2 -0
  35. package/dist/tui/app-workflows.js +5 -0
  36. package/dist/tui/components/messages.js +26 -5
  37. package/dist/tui/feedback.js +10 -6
  38. package/dist/tui/session-view.js +10 -4
  39. package/dist/tui/turn.js +4 -1
  40. package/dist/version.js +14 -0
  41. package/docs/assets/brand/jeco-256.png +0 -0
  42. package/package.json +5 -1
@@ -0,0 +1,203 @@
1
+ // OpenAI's ChatGPT OAuth protocol, implemented with Node primitives only.
2
+ //
3
+ // This module owns the authority handshake. It does not know about the TUI or
4
+ // persist anything; callers decide when an explicitly completed login becomes
5
+ // an account on disk.
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import { oauthRequest } from "./oauth-http.js";
8
+ import { OPENAI_CALLBACK_PATH, openAICallback } from "./openai-oauth-callback.js";
9
+ import { openAIAccountFromTokens, openAITokenReply, } from "./openai-oauth-tokens.js";
10
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
11
+ const AUTHORITY = "https://auth.openai.com";
12
+ const AUTHORIZE = `${AUTHORITY}/oauth/authorize`;
13
+ const TOKEN = `${AUTHORITY}/oauth/token`;
14
+ const REVOKE = `${AUTHORITY}/oauth/revoke`;
15
+ const DEVICE_CODE = `${AUTHORITY}/api/accounts/deviceauth/usercode`;
16
+ const DEVICE_POLL = `${AUTHORITY}/api/accounts/deviceauth/token`;
17
+ const DEVICE_VERIFY = `${AUTHORITY}/codex/device`;
18
+ const DEVICE_REDIRECT = `${AUTHORITY}/deviceauth/callback`;
19
+ const LOGIN_LIMIT_MS = 15 * 60_000;
20
+ export async function beginBrowserLogin() {
21
+ const verifier = randomBytes(64).toString("base64url");
22
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
23
+ const state = randomBytes(32).toString("base64url");
24
+ const callback = await openAICallback(state);
25
+ const redirectUri = `http://localhost:${callback.port}${OPENAI_CALLBACK_PATH}`;
26
+ const authorize = new URL(AUTHORIZE);
27
+ authorize.search = new URLSearchParams({
28
+ response_type: "code",
29
+ client_id: CLIENT_ID,
30
+ redirect_uri: redirectUri,
31
+ scope: "openid profile email offline_access",
32
+ code_challenge: challenge,
33
+ code_challenge_method: "S256",
34
+ state,
35
+ id_token_add_organizations: "true",
36
+ codex_cli_simplified_flow: "true",
37
+ originator: "jecode",
38
+ }).toString();
39
+ return {
40
+ url: authorize.href,
41
+ async complete(signal) {
42
+ try {
43
+ const code = await abortable(callback.code, signal);
44
+ const account = openAIAccountFromTokens(await exchange({
45
+ authorizationCode: code,
46
+ verifier,
47
+ redirectUri,
48
+ }, signal));
49
+ await callback.respond(true);
50
+ return account;
51
+ }
52
+ catch (error) {
53
+ await callback.respond(false);
54
+ throw error;
55
+ }
56
+ finally {
57
+ await callback.close();
58
+ }
59
+ },
60
+ close: callback.close,
61
+ };
62
+ }
63
+ export async function beginDeviceLogin(signal) {
64
+ const start = await oauthRequest(DEVICE_CODE, { contentType: "application/json", value: { client_id: CLIENT_ID } }, signal);
65
+ const value = record(start.value) ? start.value : {};
66
+ const deviceAuthId = required(value["device_auth_id"], "device authorization id");
67
+ const code = required(value["user_code"] ?? value["usercode"], "device code");
68
+ const interval = intervalSeconds(value["interval"]);
69
+ return {
70
+ url: DEVICE_VERIFY,
71
+ code,
72
+ async complete(waitSignal) {
73
+ const combined = combine(signal, waitSignal);
74
+ const pending = await pollDevice(deviceAuthId, code, interval, combined);
75
+ return openAIAccountFromTokens(await exchange(pending, combined));
76
+ },
77
+ close: async () => { },
78
+ };
79
+ }
80
+ export async function refreshOpenAITokens(account, signal) {
81
+ const response = await oauthRequest(TOKEN, {
82
+ contentType: "application/json",
83
+ value: {
84
+ client_id: CLIENT_ID,
85
+ grant_type: "refresh_token",
86
+ refresh_token: account.refreshToken,
87
+ },
88
+ }, signal);
89
+ const token = openAITokenReply(response.value, account.refreshToken);
90
+ const refreshed = openAIAccountFromTokens(token);
91
+ if (refreshed.accountId !== account.accountId) {
92
+ throw new Error("OpenAI refreshed a different ChatGPT account · sign in again");
93
+ }
94
+ return {
95
+ ...refreshed,
96
+ ...(refreshed.email === undefined && account.email !== undefined ? { email: account.email } : {}),
97
+ ...(refreshed.plan === undefined && account.plan !== undefined ? { plan: account.plan } : {}),
98
+ };
99
+ }
100
+ export async function revokeOpenAITokens(account, signal) {
101
+ await oauthRequest(REVOKE, {
102
+ contentType: "application/json",
103
+ value: {
104
+ token: account.refreshToken,
105
+ token_type_hint: "refresh_token",
106
+ client_id: CLIENT_ID,
107
+ },
108
+ }, signal);
109
+ }
110
+ async function exchange(code, signal) {
111
+ const response = await oauthRequest(TOKEN, {
112
+ contentType: "application/x-www-form-urlencoded",
113
+ value: new URLSearchParams({
114
+ grant_type: "authorization_code",
115
+ client_id: CLIENT_ID,
116
+ code: code.authorizationCode,
117
+ code_verifier: code.verifier,
118
+ redirect_uri: code.redirectUri,
119
+ }),
120
+ }, signal);
121
+ return openAITokenReply(response.value);
122
+ }
123
+ async function pollDevice(deviceAuthId, userCode, interval, signal) {
124
+ const started = Date.now();
125
+ while (Date.now() - started < LOGIN_LIMIT_MS) {
126
+ const response = await oauthRequest(DEVICE_POLL, {
127
+ contentType: "application/json",
128
+ value: { device_auth_id: deviceAuthId, user_code: userCode },
129
+ }, signal, [200, 403, 404]);
130
+ if (response.status === 200) {
131
+ const value = record(response.value) ? response.value : {};
132
+ return {
133
+ authorizationCode: required(value["authorization_code"], "authorization code"),
134
+ verifier: required(value["code_verifier"], "code verifier"),
135
+ redirectUri: DEVICE_REDIRECT,
136
+ };
137
+ }
138
+ await sleep(interval * 1_000, signal);
139
+ }
140
+ throw new Error("ChatGPT device sign-in timed out after 15 minutes");
141
+ }
142
+ function intervalSeconds(value) {
143
+ const parsed = typeof value === "string" ? Number(value.trim()) : value;
144
+ return typeof parsed === "number" && Number.isFinite(parsed)
145
+ ? Math.max(1, Math.min(30, Math.floor(parsed)))
146
+ : 5;
147
+ }
148
+ function required(value, label) {
149
+ const found = optional(value);
150
+ if (found === undefined)
151
+ throw new Error(`OpenAI sign-in returned no ${label}`);
152
+ return found;
153
+ }
154
+ function optional(value) {
155
+ return typeof value === "string" && value !== "" ? value : undefined;
156
+ }
157
+ function record(value) {
158
+ return typeof value === "object" && value !== null && !Array.isArray(value);
159
+ }
160
+ function combine(left, right) {
161
+ if (left === undefined)
162
+ return right;
163
+ if (right === undefined)
164
+ return left;
165
+ return AbortSignal.any([left, right]);
166
+ }
167
+ function abortable(promise, signal) {
168
+ if (signal === undefined)
169
+ return promise;
170
+ if (signal.aborted)
171
+ return Promise.reject(abortReason(signal));
172
+ return new Promise((resolve, reject) => {
173
+ const onAbort = () => reject(abortReason(signal));
174
+ signal.addEventListener("abort", onAbort, { once: true });
175
+ promise.then((value) => {
176
+ signal.removeEventListener("abort", onAbort);
177
+ resolve(value);
178
+ }, (error) => {
179
+ signal.removeEventListener("abort", onAbort);
180
+ reject(error);
181
+ });
182
+ });
183
+ }
184
+ function sleep(ms, signal) {
185
+ return new Promise((resolve, reject) => {
186
+ if (signal?.aborted === true) {
187
+ reject(abortReason(signal));
188
+ return;
189
+ }
190
+ const timer = setTimeout(() => {
191
+ signal?.removeEventListener("abort", onAbort);
192
+ resolve();
193
+ }, ms);
194
+ const onAbort = () => {
195
+ clearTimeout(timer);
196
+ reject(signal === undefined ? new Error("cancelled") : abortReason(signal));
197
+ };
198
+ signal?.addEventListener("abort", onAbort, { once: true });
199
+ });
200
+ }
201
+ function abortReason(signal) {
202
+ return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
203
+ }
@@ -2,8 +2,10 @@
2
2
  import { heading } from "./tui/picker.js";
3
3
  import { PROVIDERS } from "./providers/index.js";
4
4
  import { readSettings } from "./settings.js";
5
- import { askForKey } from "./credential-commands.js";
5
+ import { authenticationNeed, ensureProviderAuthentication, } from "./credential-commands.js";
6
6
  import { providerFailure } from "./provider-errors.js";
7
+ import { providerLabel } from "./provider-label.js";
8
+ import { compatibleEffort } from "./effort.js";
7
9
  /**
8
10
  * The provider menu.
9
11
  *
@@ -34,32 +36,22 @@ export async function providersCommand(session, host, behavior = {}) {
34
36
  // Picking the provider already in use is not a no-op when it cannot run:
35
37
  // it is how the user asks to fix the reason it cannot.
36
38
  if (chosen.id === session.provider.id) {
37
- const blocked = chosen.blocked();
38
- if (blocked !== undefined) {
39
- if (!isCredentialBlocker(chosen, blocked)) {
40
- host.emit({ kind: "notice", text: blocked, tone: "error" });
41
- return false;
42
- }
43
- await askForKey(chosen.keyVar, host, session.palette);
44
- if (chosen.blocked() !== undefined)
45
- return false;
46
- }
47
- return true;
39
+ if (!(await ensureProviderAuthentication(chosen, session, host)))
40
+ return false;
41
+ return session.model === ""
42
+ ? modelsCommand(session, host, { announce: false, save: behavior.save })
43
+ : true;
48
44
  }
49
45
  // A provider that cannot run is worth one offer to fix it, here, rather
50
46
  // than a note telling the user to leave and export something.
51
47
  const blocked = chosen.blocked();
52
48
  if (blocked !== undefined) {
53
- if (!isCredentialBlocker(chosen, blocked)) {
54
- host.emit({ kind: "notice", text: blocked, tone: "error" });
55
- return false;
56
- }
57
- await askForKey(chosen.keyVar, host, session.palette);
49
+ await ensureProviderAuthentication(chosen, session, host);
58
50
  const still = chosen.blocked();
59
51
  if (still !== undefined) {
60
52
  host.emit({
61
53
  kind: "notice",
62
- text: `${providerName(chosen.id)} still needs an API key · provider unchanged`,
54
+ text: `${providerLabel(chosen.id)} still needs ${authenticationNeed(chosen)} · provider unchanged`,
63
55
  tone: "warn",
64
56
  });
65
57
  return false;
@@ -70,6 +62,7 @@ export async function providersCommand(session, host, behavior = {}) {
70
62
  model: session.model,
71
63
  providerId: session.config.providerId,
72
64
  configModel: session.config.model,
65
+ effort: session.config.effort,
73
66
  };
74
67
  session.provider = chosen;
75
68
  // The model belonged to the old provider. Carrying it across would send
@@ -77,16 +70,31 @@ export async function providersCommand(session, host, behavior = {}) {
77
70
  session.model = readSettings().models?.[chosen.id] ?? chosen.defaultModel;
78
71
  session.config.providerId = chosen.id;
79
72
  session.config.model = session.model;
73
+ if (session.model === "") {
74
+ if (!(await modelsCommand(session, host, { announce: false, save: false }))) {
75
+ restoreProvider(session, before);
76
+ return false;
77
+ }
78
+ }
79
+ else {
80
+ const alignment = await alignEffort(session, host);
81
+ if (!alignment.ok) {
82
+ restoreProvider(session, before);
83
+ return false;
84
+ }
85
+ }
80
86
  if (behavior.save !== false) {
81
87
  const saved = readSettings();
82
88
  const models = { ...saved.models };
83
89
  if (session.model !== "")
84
90
  models[chosen.id] = session.model;
85
- if (!(await saveDefaults(host, { provider: chosen.id, models }))) {
86
- session.provider = before.provider;
87
- session.model = before.model;
88
- session.config.providerId = before.providerId;
89
- session.config.model = before.configModel;
91
+ const patch = {
92
+ provider: chosen.id,
93
+ models,
94
+ ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
95
+ };
96
+ if (!(await saveDefaults(host, patch))) {
97
+ restoreProvider(session, before);
90
98
  return false;
91
99
  }
92
100
  }
@@ -111,16 +119,12 @@ export async function modelsCommand(session, host, behavior = {}) {
111
119
  // to pick a model, and "go and export a variable" is not an answer.
112
120
  const blocked = provider.blocked();
113
121
  if (blocked !== undefined) {
114
- if (!isCredentialBlocker(provider, blocked)) {
115
- host.emit({ kind: "notice", text: blocked, tone: "error" });
116
- return false;
117
- }
118
- await askForKey(provider.keyVar, host, session.palette);
122
+ await ensureProviderAuthentication(provider, session, host);
119
123
  const still = provider.blocked();
120
124
  if (still !== undefined) {
121
125
  host.emit({
122
126
  kind: "notice",
123
- text: `${providerName(provider.id)} still needs an API key`,
127
+ text: `${providerLabel(provider.id)} still needs ${authenticationNeed(provider)}`,
124
128
  tone: "warn",
125
129
  });
126
130
  return false;
@@ -158,15 +162,31 @@ export async function modelsCommand(session, host, behavior = {}) {
158
162
  const chosen = ids[index];
159
163
  if (chosen === undefined)
160
164
  return false;
161
- const before = { model: session.model, configModel: session.config.model };
165
+ const before = {
166
+ model: session.model,
167
+ configModel: session.config.model,
168
+ effort: session.config.effort,
169
+ };
162
170
  session.model = chosen;
163
171
  session.config.model = chosen;
172
+ const alignment = await alignEffort(session, host);
173
+ if (!alignment.ok) {
174
+ session.model = before.model;
175
+ session.config.model = before.configModel;
176
+ session.config.effort = before.effort;
177
+ return false;
178
+ }
164
179
  if (behavior.save !== false) {
165
180
  const saved = readSettings();
166
181
  const models = { ...saved.models, [provider.id]: chosen };
167
- if (!(await saveDefaults(host, { models }))) {
182
+ const patch = {
183
+ models,
184
+ ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
185
+ };
186
+ if (!(await saveDefaults(host, patch))) {
168
187
  session.model = before.model;
169
188
  session.config.model = before.configModel;
189
+ session.config.effort = before.effort;
170
190
  return false;
171
191
  }
172
192
  }
@@ -175,19 +195,40 @@ export async function modelsCommand(session, host, behavior = {}) {
175
195
  }
176
196
  return true;
177
197
  }
178
- /** The way to put a menu up, or nothing — and the reason, already said. */
198
+ function restoreProvider(session, before) {
199
+ session.provider = before.provider;
200
+ session.model = before.model;
201
+ session.config.providerId = before.providerId;
202
+ session.config.model = before.configModel;
203
+ session.config.effort = before.effort;
204
+ }
205
+ async function alignEffort(session, host) {
206
+ if (session.provider.efforts === undefined)
207
+ return { ok: true };
208
+ let supported;
209
+ try {
210
+ supported = await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
211
+ }
212
+ catch (error) {
213
+ host.emit({
214
+ kind: "notice",
215
+ text: providerFailure(session.provider, error, true),
216
+ tone: "error",
217
+ });
218
+ return { ok: false };
219
+ }
220
+ const adjusted = compatibleEffort(session.config.effort, supported);
221
+ if (adjusted === undefined || adjusted === session.config.effort)
222
+ return { ok: true };
223
+ session.config.effort = adjusted;
224
+ return { ok: true, adjusted };
225
+ }
179
226
  function chooser(host) {
180
227
  if (host.choose === undefined) {
181
228
  host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
182
229
  }
183
230
  return host.choose;
184
231
  }
185
- function providerName(id) {
186
- return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
187
- }
188
- function isCredentialBlocker(provider, blocked) {
189
- return blocked.startsWith(`${provider.keyVar} `);
190
- }
191
232
  async function saveDefaults(host, patch) {
192
233
  if (host.saveSettings === undefined)
193
234
  return true;
@@ -0,0 +1,10 @@
1
+ // Stable human-facing names for provider identifiers.
2
+ export function providerLabel(id) {
3
+ switch (id) {
4
+ case "anthropic": return "Anthropic";
5
+ case "openai": return "OpenAI";
6
+ case "openai-codex": return "OpenAI Codex";
7
+ case "ollama": return "Ollama";
8
+ default: return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
9
+ }
10
+ }
@@ -13,7 +13,8 @@ export async function assembleAnthropic(events, onStream) {
13
13
  let stopReason;
14
14
  let stopDetails;
15
15
  let usage;
16
- for await (const raw of events) {
16
+ let complete = false;
17
+ stream: for await (const raw of events) {
17
18
  const event = raw;
18
19
  switch (event.type) {
19
20
  case "message_start":
@@ -51,10 +52,15 @@ export async function assembleAnthropic(events, onStream) {
51
52
  case "error": {
52
53
  throw new Error(`anthropic stream error: ${event.error?.message ?? "unspecified"}`);
53
54
  }
55
+ case "message_stop":
56
+ complete = true;
57
+ break stream;
54
58
  default:
55
- break; // message_start, ping, message_stop: nothing to accumulate
59
+ break; // ping and unknown forward-compatible events
56
60
  }
57
61
  }
62
+ if (!complete)
63
+ throw new Error("anthropic stream ended before message_stop");
58
64
  const content = [...blocks.entries()]
59
65
  .sort(([a], [b]) => a - b)
60
66
  .map(([, block]) => block);
@@ -9,25 +9,33 @@
9
9
  import { postSse } from "./http.js";
10
10
  import { listModels } from "./catalog.js";
11
11
  import { keyFor } from "../credentials.js";
12
+ import { EFFORTS, requireSupportedEffort } from "../effort.js";
12
13
  import { assembleAnthropic } from "./anthropic-stream.js";
13
14
  import { fromWireResponse, stopNotice, toWireMessage, toWireTool } from "./anthropic-wire.js";
14
15
  const ENDPOINT = "https://api.anthropic.com/v1/messages";
15
16
  const MODELS = "https://api.anthropic.com/v1/models?limit=100";
16
17
  const API_VERSION = "2023-06-01";
17
18
  const KEY = "ANTHROPIC_API_KEY";
18
- // Adaptive thinking and `output_config.effort` exist on the 4.6-and-later
19
- // families only. Older models reject both with a 400, so `/model claude-haiku-4-5`
20
- // would fail on send if the request shape were fixed. It follows the model.
21
- const ADAPTIVE = /^claude-(fable-5|opus-(5|4-[678])|sonnet-(5|4-6))/;
19
+ const ADAPTIVE = /^claude-(?:fable-5|mythos-(?:5|preview)|opus-(?:5|4-[678])|sonnet-(?:5|4-6))(?:-|$)/;
20
+ const MAX_WITHOUT_XHIGH = ["low", "medium", "high", "max"];
21
+ const ANTHROPIC_45_EFFORTS = ["low", "medium", "high"];
22
22
  export function supportsAdaptiveThinking(model) {
23
23
  return ADAPTIVE.test(model);
24
24
  }
25
+ export function anthropicEfforts(model) {
26
+ if (/^claude-(?:(?:opus|sonnet)-4-6|mythos-preview)(?:-|$)/.test(model)) {
27
+ return MAX_WITHOUT_XHIGH;
28
+ }
29
+ if (/^claude-opus-4-5(?:-|$)/.test(model))
30
+ return ANTHROPIC_45_EFFORTS;
31
+ return supportsAdaptiveThinking(model) ? EFFORTS : [];
32
+ }
25
33
  export const anthropic = {
26
34
  id: "anthropic",
27
35
  // Sonnet is the default because it is the one that can be left running.
28
36
  // Opus via `--model claude-opus-5`, Haiku via `--model claude-haiku-4-5`.
29
37
  defaultModel: "claude-sonnet-5",
30
- keyVar: KEY,
38
+ auth: { kind: "api-key", keyVar: KEY },
31
39
  blocked() {
32
40
  return apiKey() === undefined ? `${KEY} is not set` : undefined;
33
41
  },
@@ -36,6 +44,9 @@ export const anthropic = {
36
44
  models(signal, onStatus) {
37
45
  return listModels(MODELS, headers(requireKey()), signal, onStatus);
38
46
  },
47
+ async efforts(model) {
48
+ return anthropicEfforts(model);
49
+ },
39
50
  location: () => "cloud",
40
51
  async send(req) {
41
52
  const key = requireKey();
@@ -49,7 +60,12 @@ export const anthropic = {
49
60
  };
50
61
  if (supportsAdaptiveThinking(req.model)) {
51
62
  body["thinking"] = { type: "adaptive", display: "summarized" };
52
- body["output_config"] = { effort: req.effort };
63
+ }
64
+ const efforts = anthropicEfforts(req.model);
65
+ if (efforts.length > 0) {
66
+ body["output_config"] = {
67
+ effort: requireSupportedEffort(req.model, req.effort, efforts),
68
+ };
53
69
  }
54
70
  const events = await postSse(ENDPOINT, headers(key), body, req.signal, req.onStatus);
55
71
  const data = await assembleAnthropic(events, req.onStream);
@@ -1,7 +1,8 @@
1
1
  import { anthropic } from "./anthropic.js";
2
2
  import { openai } from "./openai.js";
3
+ import { openaiCodex } from "./openai-codex.js";
3
4
  import { configureOllama, ollama } from "./ollama.js";
4
- export const PROVIDERS = [anthropic, openai, ollama];
5
+ export const PROVIDERS = [anthropic, openai, openaiCodex, ollama];
5
6
  export function providerNames() {
6
7
  return PROVIDERS.map((provider) => provider.id);
7
8
  }
@@ -51,6 +51,9 @@ export async function assembleOllama(events, onStream) {
51
51
  calls.set(index, call);
52
52
  }
53
53
  }
54
+ if (finishReason === undefined) {
55
+ throw new Error("ollama stream ended before a finish reason");
56
+ }
54
57
  const toolCalls = [...calls.entries()]
55
58
  .sort(([a], [b]) => a - b)
56
59
  // Not every server sends an id, and the loop needs one to pair the result
@@ -25,7 +25,7 @@ export function ollamaConnection() {
25
25
  export const ollama = {
26
26
  id: "ollama",
27
27
  defaultModel: "",
28
- keyVar: KEY,
28
+ auth: { kind: "api-key", keyVar: KEY },
29
29
  // The only provider whose key is conditional: a daemon on this machine is
30
30
  // reached over loopback and asks for nothing, so demanding a key there
31
31
  // would be an invented requirement.
@@ -45,6 +45,9 @@ export const ollama = {
45
45
  const at = endpoint();
46
46
  return listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus);
47
47
  },
48
+ async efforts() {
49
+ return [];
50
+ },
48
51
  location: () => {
49
52
  try {
50
53
  return endpoint().loopback ? "local" : "cloud";