u-foo 3.0.1 → 3.0.3

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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/agents/prompts/native/tasks.js +4 -1
  3. package/src/agents/prompts/native/toolDescriptions/readImage.js +23 -0
  4. package/src/app/chat/commandExecutor.js +111 -1
  5. package/src/app/chat/commands.js +2 -1
  6. package/src/app/chat/daemonMessageRouter.js +1 -1
  7. package/src/app/chat/inputSubmitHandler.js +3 -2
  8. package/src/code/commands.js +3 -3
  9. package/src/code/context/assembler.js +17 -1
  10. package/src/code/context/planMode.js +2 -2
  11. package/src/code/context/promptLayers.js +12 -10
  12. package/src/code/context/reducers.js +35 -0
  13. package/src/code/context/transcriptSync.js +25 -5
  14. package/src/code/dispatch.js +8 -0
  15. package/src/code/imageIngest.js +367 -0
  16. package/src/code/modelCommand.js +199 -23
  17. package/src/code/nativeRunner.js +184 -20
  18. package/src/code/protocol/protocolValidator.js +3 -3
  19. package/src/code/providers/anthropicMessagesTransport.js +28 -1
  20. package/src/code/providers/index.js +2 -0
  21. package/src/code/providers/modelsCatalog.js +304 -0
  22. package/src/code/providers/openaiChatTransport.js +19 -1
  23. package/src/code/providers/visionBlocks.js +110 -0
  24. package/src/code/repl.js +37 -8
  25. package/src/code/runtime/taskControl.js +177 -53
  26. package/src/code/runtime/taskFocus.js +30 -10
  27. package/src/code/runtime/taskLoop.js +12 -1
  28. package/src/code/runtime/taskRun.js +10 -1
  29. package/src/code/thinkingLevels.js +132 -0
  30. package/src/code/tools/readImage.js +110 -0
  31. package/src/code/tools/taskRun.js +118 -0
  32. package/src/config.js +10 -1
  33. package/src/ui/format/index.js +103 -5
  34. package/src/ui/ink/ChatApp.js +137 -25
  35. package/src/ui/ink/MultilineInput.js +38 -2
  36. package/src/ui/ink/UcodeApp.js +102 -14
  37. package/src/ui/ink/chatLogModel.js +238 -32
  38. package/src/ui/ink/chatReducer.js +18 -6
@@ -5,6 +5,7 @@ const {
5
5
  resolveKimiUpstreamCredentials,
6
6
  } = require("../agents/providers/credentials/kimi");
7
7
  const { runToolCall } = require("./dispatch");
8
+ const { runTaskRunTool } = require("./tools/taskRun");
8
9
  const { appendUsageRecord } = require("./usageStore");
9
10
  const {
10
11
  persistToolResultToContext,
@@ -49,20 +50,31 @@ const {
49
50
  } = require("./protocol");
50
51
  const { stableStringify } = require("./context/stableJson");
51
52
  const { getReadToolDescription } = require("../agents/prompts/native/toolDescriptions/read");
53
+ const { getReadImageToolDescription } = require("../agents/prompts/native/toolDescriptions/readImage");
52
54
  const { getWriteToolDescription } = require("../agents/prompts/native/toolDescriptions/write");
53
55
  const { getEditToolDescription } = require("../agents/prompts/native/toolDescriptions/edit");
54
56
  const { getBashToolDescription } = require("../agents/prompts/native/toolDescriptions/bash");
55
57
 
56
58
  const CORE_TOOL_NAMES = new Set([
57
59
  "read",
60
+ "read_image",
58
61
  "write",
59
62
  "edit",
60
63
  "bash",
61
64
  "artifact_read",
62
65
  "plan_graph",
66
+ "task_run",
63
67
  "ask_user",
64
68
  ]);
65
- const EXECUTABLE_GRAPH_TOOLS = new Set(["read", "write", "edit", "bash", "artifact_read"]);
69
+ const EXECUTABLE_GRAPH_TOOLS = new Set([
70
+ "read",
71
+ "read_image",
72
+ "write",
73
+ "edit",
74
+ "bash",
75
+ "artifact_read",
76
+ ]);
77
+ const CONTROL_PLANE_TOOLS = new Set(["plan_graph", "task_run"]);
66
78
  const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
67
79
  const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
68
80
  const DEFAULT_KIMI_BASE_URL = "https://api.kimi.com/coding/v1";
@@ -78,11 +90,9 @@ const DEFAULT_NATIVE_TIMEOUT_MS = 43200000; // 12 hours
78
90
  // via UFOO_UCODE_MAX_TOKENS (positive integer).
79
91
  const DEFAULT_OPENAI_MAX_TOKENS = 131072;
80
92
  const DEFAULT_ANTHROPIC_MAX_TOKENS = 64000;
81
- // Extended thinking is on by default for the anthropic transport; the budget
82
- // stays well below the 64K max_tokens cap as the Messages API requires.
83
- // UFOO_UCODE_THINKING_BUDGET_TOKENS overrides; 0 or a non-numeric value
84
- // disables thinking (the payload then omits the field entirely).
85
- const DEFAULT_ANTHROPIC_THINKING_BUDGET_TOKENS = 10000;
93
+ // Extended thinking defaults live in thinkingLevels.js (medium = 10k).
94
+ // UFOO_UCODE_THINKING=off|low|medium|high|max selects a preset; numeric
95
+ // UFOO_UCODE_THINKING_BUDGET_TOKENS still overrides. 0 disables thinking.
86
96
  // Prompt caching is GA on the current Messages API: cache_control blocks need
87
97
  // no anthropic-beta header. Kept as a constant so the marker shape stays in
88
98
  // one place (system block + last history message, 2 of the 4 allowed
@@ -116,14 +126,40 @@ function resolveMaxTokens(fallback) {
116
126
  return normalizePositiveInt(process.env.UFOO_UCODE_MAX_TOKENS, fallback);
117
127
  }
118
128
 
119
- function resolveThinkingBudgetTokens() {
120
- const raw = process.env.UFOO_UCODE_THINKING_BUDGET_TOKENS;
121
- if (raw === undefined || raw === null || String(raw).trim() === "") {
122
- return DEFAULT_ANTHROPIC_THINKING_BUDGET_TOKENS;
129
+ function resolveThinkingBudgetTokens(options = {}) {
130
+ const { resolveThinkingFromEnvAndConfig } = require("./thinkingLevels");
131
+ const { loadGlobalUcodeConfig } = require("../config");
132
+ let configLevel = String(options.configLevel || "").trim();
133
+ if (!configLevel) {
134
+ try {
135
+ configLevel = String((loadGlobalUcodeConfig() || {}).ucodeThinking || "").trim();
136
+ } catch {
137
+ configLevel = "";
138
+ }
123
139
  }
124
- const parsed = Number.parseInt(String(raw), 10);
125
- if (!Number.isFinite(parsed) || parsed <= 0) return 0;
126
- return Math.floor(parsed);
140
+ const resolved = resolveThinkingFromEnvAndConfig({
141
+ env: options.env || process.env,
142
+ configLevel,
143
+ });
144
+ return resolved.budgetTokens;
145
+ }
146
+
147
+ function resolveReasoningEffort(options = {}) {
148
+ const { resolveThinkingFromEnvAndConfig } = require("./thinkingLevels");
149
+ const { loadGlobalUcodeConfig } = require("../config");
150
+ let configLevel = String(options.configLevel || "").trim();
151
+ if (!configLevel) {
152
+ try {
153
+ configLevel = String((loadGlobalUcodeConfig() || {}).ucodeThinking || "").trim();
154
+ } catch {
155
+ configLevel = "";
156
+ }
157
+ }
158
+ const resolved = resolveThinkingFromEnvAndConfig({
159
+ env: options.env || process.env,
160
+ configLevel,
161
+ });
162
+ return resolved.reasoningEffort || "";
127
163
  }
128
164
 
129
165
  function toUsageInt(value) {
@@ -365,6 +401,23 @@ function buildCoreToolSpecs() {
365
401
  },
366
402
  },
367
403
  },
404
+ {
405
+ type: "function",
406
+ function: {
407
+ name: "read_image",
408
+ description: getReadImageToolDescription(),
409
+ parameters: {
410
+ type: "object",
411
+ properties: {
412
+ path: {
413
+ type: "string",
414
+ description: "Workspace-relative path to a png, jpeg, gif, or webp image.",
415
+ },
416
+ },
417
+ required: ["path"],
418
+ },
419
+ },
420
+ },
368
421
  {
369
422
  type: "function",
370
423
  function: {
@@ -446,12 +499,13 @@ function buildCoreToolSpecs() {
446
499
  function: {
447
500
  name: "plan_graph",
448
501
  description: [
449
- "Manage the persistent Plan Graph and asynchronous TaskRuns.",
450
- "Use create, patch, inspect, or cancel_graph for graph operations, and control for TaskRun lifecycle.",
451
- "`control.start_task` starts a `task_loop` asynchronously and returns immediately.",
502
+ "Manage the persistent Plan Graph and graph-bound TaskRuns.",
503
+ "TaskRuns are orthogonal to Plan Mode; for a standalone TaskRun without a plan, use `task_run` instead.",
504
+ "Use create, patch, inspect, or cancel_graph for graph operations, and control for graph-bound TaskRun lifecycle.",
505
+ "`control.start_task` starts a graph `task_loop` asynchronously and returns immediately.",
452
506
  "Use `inline_llm` for work handled by the current graph owner,",
453
507
  "`expand` for tasks that must be lowered into child nodes,",
454
- "and `task_loop` for asynchronous work in an independent TaskLoop.",
508
+ "and `task_loop` for asynchronous work in an independent TaskLoop attached to a plan node.",
455
509
  "Do not call `plan_graph` together with data-plane tools in the same assistant turn.",
456
510
  ].join(" "),
457
511
  parameters: {
@@ -517,6 +571,63 @@ function buildCoreToolSpecs() {
517
571
  },
518
572
  },
519
573
  },
574
+ {
575
+ type: "function",
576
+ function: {
577
+ name: "task_run",
578
+ description: [
579
+ "Start, inspect, cancel, fail, or complete a TaskRun.",
580
+ "TaskRuns are orthogonal to Plan Mode and do not require a plan_graph.",
581
+ "Use operation=start with an objective for a standalone single-point TaskRun; it returns immediately.",
582
+ "On complex multi-goal work, decompose into concrete objectives and start one or more TaskRuns.",
583
+ "Use plan_graph control.start_task only when the TaskRun is attached to a plan_graph task_loop node.",
584
+ "Do not call `task_run` together with data-plane tools in the same assistant turn.",
585
+ ].join(" "),
586
+ parameters: {
587
+ type: "object",
588
+ properties: {
589
+ operation: {
590
+ type: "string",
591
+ enum: ["start", "cancel", "fail", "complete", "inspect"],
592
+ description: [
593
+ "start creates a standalone TaskRun from objective;",
594
+ "cancel/fail/complete/inspect address an existing taskRunId",
595
+ "(cancel/fail may also use nodeId for graph-bound runs).",
596
+ ].join(" "),
597
+ },
598
+ objective: {
599
+ type: "string",
600
+ description: "Required for start: concrete TaskRun objective.",
601
+ },
602
+ title: {
603
+ type: "string",
604
+ description: "Optional short title for start.",
605
+ },
606
+ taskRunId: {
607
+ type: "string",
608
+ description: "TaskRun id for cancel, fail, complete, or inspect.",
609
+ },
610
+ nodeId: {
611
+ type: "string",
612
+ description: "Optional graph node id for cancel/fail of a graph-bound TaskRun.",
613
+ },
614
+ reason: {
615
+ type: "string",
616
+ description: "Optional reason for cancel or fail.",
617
+ },
618
+ result: {
619
+ type: "object",
620
+ description: "Optional result payload for complete (TaskLoop owner).",
621
+ },
622
+ commandId: {
623
+ type: "string",
624
+ description: "Optional idempotency key for explicit replay.",
625
+ },
626
+ },
627
+ required: ["operation"],
628
+ },
629
+ },
630
+ },
520
631
  {
521
632
  type: "function",
522
633
  function: {
@@ -772,6 +883,51 @@ function runCoreTool({
772
883
  };
773
884
  }
774
885
 
886
+ if (normalizedTool === "task_run") {
887
+ const state = executionState && typeof executionState === "object"
888
+ ? executionState
889
+ : emptyExecutionState();
890
+ const result = runTaskRunTool(safeArgs, {
891
+ executionState: state,
892
+ runTool: ({ node, args: nestedArgs, tool: nestedTool, stepId }) => {
893
+ const nested = runCoreTool({
894
+ tool: nestedTool,
895
+ args: nestedArgs,
896
+ workspaceRoot,
897
+ onToolEvent,
898
+ sessionId,
899
+ onArtifactPersisted,
900
+ executionState: state,
901
+ origin: {
902
+ kind: "task_run",
903
+ taskRunId: String(safeArgs.taskRunId || ""),
904
+ nodeId: stepId || (node && node.id) || "",
905
+ attempt: Number(node && node.attempt) || 0,
906
+ },
907
+ });
908
+ return nested;
909
+ },
910
+ });
911
+ const ok = result.ok !== false && result.status !== "rejected";
912
+ emitToolEvent(onToolEvent, {
913
+ tool: "task_run",
914
+ phase: ok ? "end" : "error",
915
+ args: safeArgs,
916
+ result,
917
+ error: ok
918
+ ? ""
919
+ : (Array.isArray(result.errors)
920
+ ? result.errors.map((e) => e.message || e.code).join("; ")
921
+ : (result.error || "task_run rejected")),
922
+ origin,
923
+ });
924
+ return {
925
+ ...result,
926
+ ok,
927
+ executionState: result.executionState || state,
928
+ };
929
+ }
930
+
775
931
  if (normalizedTool === "ask_user") {
776
932
  const state = executionState && typeof executionState === "object"
777
933
  ? executionState
@@ -970,6 +1126,12 @@ async function runOpenAiLikeTurn({
970
1126
  // Kimi k3 rejects any temperature other than 1.
971
1127
  temperature: normalizeProvider(provider) === "kimi" ? 1 : 0,
972
1128
  };
1129
+ const reasoningEffort = resolveReasoningEffort();
1130
+ if (reasoningEffort) {
1131
+ // OpenAI-compatible gateways that support reasoning models accept this;
1132
+ // unknown fields are typically ignored by plain chat models.
1133
+ payload.reasoning_effort = reasoningEffort;
1134
+ }
973
1135
 
974
1136
  const headers = {
975
1137
  "content-type": "application/json",
@@ -1715,16 +1877,16 @@ async function runNativeLoop({
1715
1877
  await withFaultPoint("before_tool_exec", () => {});
1716
1878
 
1717
1879
  const callNames = pendingCalls.map((call) => String(call.name || "").trim().toLowerCase());
1718
- const hasPlanGraph = callNames.includes("plan_graph");
1880
+ const hasControlPlane = callNames.some((name) => CONTROL_PLANE_TOOLS.has(name));
1719
1881
  const hasAskUser = callNames.includes("ask_user");
1720
1882
  const hasDataTool = callNames.some((name) => EXECUTABLE_GRAPH_TOOLS.has(name));
1721
- if (hasPlanGraph && hasDataTool) {
1883
+ if (hasControlPlane && hasDataTool) {
1722
1884
  // prepareToolCalls already appended the assistant tool_calls / tool_use
1723
1885
  // message; every declared call must get a contiguous tool result via ledger.
1724
1886
  const rejected = {
1725
1887
  ok: false,
1726
1888
  status: "rejected",
1727
- error: "Do not mix plan_graph with data-plane tools in the same turn",
1889
+ error: "Do not mix plan_graph/task_run with data-plane tools in the same turn",
1728
1890
  code: "MIXED_PLAN_AND_DATA_TOOLS",
1729
1891
  };
1730
1892
  for (const pending of pendingCalls) {
@@ -2078,6 +2240,8 @@ module.exports = {
2078
2240
  resolveCompletionUrl,
2079
2241
  resolveAnthropicMessagesUrl,
2080
2242
  resolveTransport,
2243
+ resolveThinkingBudgetTokens,
2244
+ resolveReasoningEffort,
2081
2245
  buildCoreToolSpecs,
2082
2246
  buildAnthropicToolSpecs,
2083
2247
  };
@@ -78,7 +78,7 @@ function validateDeclaredBatch(ledger = null, {
78
78
 
79
79
  const names = calls.map((c) => c.name);
80
80
  const hasAskUser = names.includes("ask_user");
81
- const hasPlanGraph = names.includes("plan_graph");
81
+ const hasControlPlane = names.includes("plan_graph") || names.includes("task_run");
82
82
  const dataSet = dataPlaneTools instanceof Set
83
83
  ? dataPlaneTools
84
84
  : new Set(["read", "write", "edit", "bash", "artifact_read"]);
@@ -90,10 +90,10 @@ function validateDeclaredBatch(ledger = null, {
90
90
  message: "ask_user must be the only tool call in the turn",
91
91
  });
92
92
  }
93
- if (rejectPlanWithData && hasPlanGraph && hasData) {
93
+ if (rejectPlanWithData && hasControlPlane && hasData) {
94
94
  errors.push({
95
95
  code: "MIXED_PLAN_AND_DATA_TOOLS",
96
- message: "Do not mix plan_graph with data-plane tools in the same turn",
96
+ message: "Do not mix plan_graph/task_run with data-plane tools in the same turn",
97
97
  });
98
98
  }
99
99
 
@@ -1,6 +1,12 @@
1
1
  "use strict";
2
2
 
3
3
  const { assertTransport } = require("./transportContract");
4
+ const {
5
+ extractVisionPayload,
6
+ stripVisionBase64,
7
+ visionSummaryText,
8
+ toAnthropicImageBlock,
9
+ } = require("./visionBlocks");
4
10
 
5
11
  /**
6
12
  * Anthropic Messages API transport adapter.
@@ -70,11 +76,32 @@ function createAnthropicMessagesTransport(deps = {}) {
70
76
  }));
71
77
  },
72
78
  appendToolResult({ collected, call, toolResult }) {
79
+ const vision = extractVisionPayload(toolResult);
80
+ const isError = Boolean(!toolResult || toolResult.ok === false);
81
+ if (vision) {
82
+ const textPayload = stripVisionBase64(toolResult);
83
+ collected.push({
84
+ type: "tool_result",
85
+ tool_use_id: String(call.source.id || ""),
86
+ content: [
87
+ {
88
+ type: "text",
89
+ text: clipText(
90
+ `${visionSummaryText(vision, toolResult)}\n${toJsonString(textPayload)}`,
91
+ 12000,
92
+ ),
93
+ },
94
+ toAnthropicImageBlock(vision),
95
+ ],
96
+ is_error: isError,
97
+ });
98
+ return;
99
+ }
73
100
  collected.push({
74
101
  type: "tool_result",
75
102
  tool_use_id: String(call.source.id || ""),
76
103
  content: clipText(toJsonString(toolResult), 12000),
77
- is_error: Boolean(!toolResult || toolResult.ok === false),
104
+ is_error: isError,
78
105
  });
79
106
  },
80
107
  flushToolResults({ messages, collected }) {
@@ -4,4 +4,6 @@ module.exports = {
4
4
  ...require("./transportContract"),
5
5
  ...require("./openaiChatTransport"),
6
6
  ...require("./anthropicMessagesTransport"),
7
+ ...require("./modelsCatalog"),
8
+ ...require("./visionBlocks"),
7
9
  };
@@ -0,0 +1,304 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Live provider model catalog via the OpenAI-compatible / Anthropic models route.
5
+ *
6
+ * Used by /model suggestions and settings validation so ucode only offers
7
+ * (and preferably accepts) models the configured endpoint actually lists.
8
+ */
9
+
10
+ const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
11
+ const DEFAULT_TIMEOUT_MS = 8000;
12
+ const CACHE_TTL_MS = 60_000;
13
+
14
+ /** @type {Map<string, { at: number, result: object }>} */
15
+ const modelsCache = new Map();
16
+
17
+ function clipText(value = "", maxChars = 400) {
18
+ const text = String(value || "");
19
+ if (text.length <= maxChars) return text;
20
+ return `${text.slice(0, maxChars)}…`;
21
+ }
22
+
23
+ function resolveOpenAiModelsUrl(baseUrl = "") {
24
+ const raw = String(baseUrl || "").trim();
25
+ if (!raw) return "";
26
+ const normalized = raw.replace(/\/+$/, "");
27
+ if (/\/models$/i.test(normalized)) return normalized;
28
+ if (/\/chat\/completions$/i.test(normalized)) {
29
+ return normalized.replace(/\/chat\/completions$/i, "/models");
30
+ }
31
+ if (/\/v1$/i.test(normalized)) return `${normalized}/models`;
32
+ if (/\/api$/i.test(normalized)) return `${normalized}/v1/models`;
33
+ return `${normalized}/models`;
34
+ }
35
+
36
+ function resolveAnthropicModelsUrl(baseUrl = "") {
37
+ const raw = String(baseUrl || "").trim() || DEFAULT_ANTHROPIC_BASE_URL;
38
+ const normalized = raw.replace(/\/+$/, "");
39
+ if (/\/models$/i.test(normalized)) return normalized;
40
+ if (/\/messages$/i.test(normalized)) {
41
+ return normalized.replace(/\/messages$/i, "/models");
42
+ }
43
+ if (/\/v1$/i.test(normalized)) return `${normalized}/models`;
44
+ if (/\/api$/i.test(normalized)) return `${normalized}/v1/models`;
45
+ return `${normalized}/models`;
46
+ }
47
+
48
+ function resolveModelsUrl({ transport = "", baseUrl = "" } = {}) {
49
+ if (String(transport || "") === "anthropic-messages") {
50
+ return resolveAnthropicModelsUrl(baseUrl);
51
+ }
52
+ return resolveOpenAiModelsUrl(baseUrl);
53
+ }
54
+
55
+ function cacheKey({ transport = "", baseUrl = "", apiKey = "", provider = "" } = {}) {
56
+ const keyTail = apiKey ? String(apiKey).slice(-8) : "";
57
+ return `${provider}|${transport}|${baseUrl}|${keyTail}`;
58
+ }
59
+
60
+ function extractModelIds(payload) {
61
+ const ids = [];
62
+ const seen = new Set();
63
+ const push = (value) => {
64
+ const id = String(value || "").trim();
65
+ if (!id || seen.has(id)) return;
66
+ seen.add(id);
67
+ ids.push(id);
68
+ };
69
+
70
+ if (!payload || typeof payload !== "object") return ids;
71
+
72
+ if (Array.isArray(payload.data)) {
73
+ for (const item of payload.data) {
74
+ if (!item) continue;
75
+ if (typeof item === "string") push(item);
76
+ else push(item.id || item.model || item.name);
77
+ }
78
+ }
79
+
80
+ if (Array.isArray(payload.models)) {
81
+ for (const item of payload.models) {
82
+ if (!item) continue;
83
+ if (typeof item === "string") push(item);
84
+ else push(item.id || item.model || item.name);
85
+ }
86
+ }
87
+
88
+ return ids;
89
+ }
90
+
91
+ function buildListHeaders({ transport = "", apiKey = "" } = {}) {
92
+ const headers = {
93
+ Accept: "application/json",
94
+ };
95
+ const key = String(apiKey || "").trim();
96
+ if (!key) return headers;
97
+
98
+ if (String(transport || "") === "anthropic-messages") {
99
+ headers["x-api-key"] = key;
100
+ headers["anthropic-version"] = "2023-06-01";
101
+ } else {
102
+ headers.Authorization = `Bearer ${key}`;
103
+ }
104
+ return headers;
105
+ }
106
+
107
+ /**
108
+ * Fetch the provider's models catalog.
109
+ * @returns {Promise<{
110
+ * ok: boolean,
111
+ * models: string[],
112
+ * url: string,
113
+ * error: string,
114
+ * status: number,
115
+ * cached: boolean,
116
+ * }>}
117
+ */
118
+ async function listProviderModels(options = {}) {
119
+ const transport = String(options.transport || "openai-chat").trim() || "openai-chat";
120
+ const baseUrl = String(options.baseUrl || "").trim();
121
+ const apiKey = String(options.apiKey || "").trim();
122
+ const provider = String(options.provider || "").trim();
123
+ const timeoutMs = Math.max(1000, Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS);
124
+ const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : fetch;
125
+ const skipCache = options.skipCache === true;
126
+ const url = resolveModelsUrl({ transport, baseUrl });
127
+
128
+ if (!url) {
129
+ return {
130
+ ok: false,
131
+ models: [],
132
+ url: "",
133
+ error: "models url unavailable (set ucode base url)",
134
+ status: 0,
135
+ cached: false,
136
+ };
137
+ }
138
+
139
+ const key = cacheKey({ transport, baseUrl, apiKey, provider });
140
+ if (!skipCache) {
141
+ const hit = modelsCache.get(key);
142
+ if (hit && (Date.now() - hit.at) < CACHE_TTL_MS) {
143
+ return { ...hit.result, cached: true };
144
+ }
145
+ }
146
+
147
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
148
+ const timer = controller
149
+ ? setTimeout(() => {
150
+ try { controller.abort(); } catch { /* ignore */ }
151
+ }, timeoutMs)
152
+ : null;
153
+ if (timer && typeof timer.unref === "function") timer.unref();
154
+
155
+ try {
156
+ const response = await fetchImpl(url, {
157
+ method: "GET",
158
+ headers: buildListHeaders({ transport, apiKey }),
159
+ signal: controller ? controller.signal : undefined,
160
+ });
161
+ const status = Number(response && response.status) || 0;
162
+ const bodyText = await response.text().catch(() => "");
163
+ let payload = null;
164
+ try {
165
+ payload = bodyText ? JSON.parse(bodyText) : null;
166
+ } catch {
167
+ payload = null;
168
+ }
169
+
170
+ if (!response.ok) {
171
+ const result = {
172
+ ok: false,
173
+ models: [],
174
+ url,
175
+ error: `models route failed (${status}): ${clipText(bodyText || response.statusText || "unknown")}`,
176
+ status,
177
+ cached: false,
178
+ };
179
+ return result;
180
+ }
181
+
182
+ const models = extractModelIds(payload);
183
+ const result = {
184
+ ok: true,
185
+ models,
186
+ url,
187
+ error: models.length === 0 ? "models route returned an empty catalog" : "",
188
+ status,
189
+ cached: false,
190
+ };
191
+ // Cache successful responses even when empty — avoids hammering a broken gateway.
192
+ modelsCache.set(key, { at: Date.now(), result: { ...result, cached: false } });
193
+ return result;
194
+ } catch (err) {
195
+ const message = err && err.name === "AbortError"
196
+ ? `models route timed out after ${timeoutMs}ms`
197
+ : (err && err.message ? err.message : String(err || "models route failed"));
198
+ return {
199
+ ok: false,
200
+ models: [],
201
+ url,
202
+ error: message,
203
+ status: 0,
204
+ cached: false,
205
+ };
206
+ } finally {
207
+ if (timer) clearTimeout(timer);
208
+ }
209
+ }
210
+
211
+ function modelSupported(model = "", models = []) {
212
+ const needle = String(model || "").trim();
213
+ if (!needle) return false;
214
+ const list = Array.isArray(models) ? models : [];
215
+ return list.some((id) => String(id || "").trim() === needle);
216
+ }
217
+
218
+ /**
219
+ * Validate a model id against the live catalog.
220
+ * Soft mode: when the catalog cannot be fetched, allow with a warning.
221
+ */
222
+ async function confirmModelSupported(options = {}) {
223
+ const model = String(options.model || "").trim();
224
+ if (!model) {
225
+ return {
226
+ ok: false,
227
+ allowed: false,
228
+ model: "",
229
+ models: [],
230
+ error: "model id is empty",
231
+ warning: "",
232
+ catalog: null,
233
+ };
234
+ }
235
+
236
+ const catalog = await listProviderModels(options);
237
+ if (!catalog.ok) {
238
+ return {
239
+ ok: false,
240
+ allowed: options.strict === true ? false : true,
241
+ model,
242
+ models: [],
243
+ error: catalog.error || "models route unavailable",
244
+ warning: options.strict === true
245
+ ? ""
246
+ : `could not confirm model via models route (${catalog.error || "unavailable"}); accepting ${model}`,
247
+ catalog,
248
+ };
249
+ }
250
+
251
+ if (catalog.models.length === 0) {
252
+ return {
253
+ ok: false,
254
+ allowed: options.strict === true ? false : true,
255
+ model,
256
+ models: [],
257
+ error: catalog.error || "empty models catalog",
258
+ warning: options.strict === true
259
+ ? ""
260
+ : `models route returned no models; accepting ${model}`,
261
+ catalog,
262
+ };
263
+ }
264
+
265
+ if (!modelSupported(model, catalog.models)) {
266
+ const sample = catalog.models.slice(0, 8).join(", ");
267
+ const more = catalog.models.length > 8 ? ` (+${catalog.models.length - 8} more)` : "";
268
+ return {
269
+ ok: false,
270
+ allowed: false,
271
+ model,
272
+ models: catalog.models,
273
+ error: `model "${model}" is not in the provider catalog${sample ? ` (available: ${sample}${more})` : ""}`,
274
+ warning: "",
275
+ catalog,
276
+ };
277
+ }
278
+
279
+ return {
280
+ ok: true,
281
+ allowed: true,
282
+ model,
283
+ models: catalog.models,
284
+ error: "",
285
+ warning: "",
286
+ catalog,
287
+ };
288
+ }
289
+
290
+ function clearModelsCache() {
291
+ modelsCache.clear();
292
+ }
293
+
294
+ module.exports = {
295
+ resolveOpenAiModelsUrl,
296
+ resolveAnthropicModelsUrl,
297
+ resolveModelsUrl,
298
+ listProviderModels,
299
+ confirmModelSupported,
300
+ modelSupported,
301
+ extractModelIds,
302
+ clearModelsCache,
303
+ CACHE_TTL_MS,
304
+ };