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.
- package/package.json +1 -1
- package/src/agents/prompts/native/tasks.js +4 -1
- package/src/agents/prompts/native/toolDescriptions/readImage.js +23 -0
- package/src/app/chat/commandExecutor.js +111 -1
- package/src/app/chat/commands.js +2 -1
- package/src/app/chat/daemonMessageRouter.js +1 -1
- package/src/app/chat/inputSubmitHandler.js +3 -2
- package/src/code/commands.js +3 -3
- package/src/code/context/assembler.js +17 -1
- package/src/code/context/planMode.js +2 -2
- package/src/code/context/promptLayers.js +12 -10
- package/src/code/context/reducers.js +35 -0
- package/src/code/context/transcriptSync.js +25 -5
- package/src/code/dispatch.js +8 -0
- package/src/code/imageIngest.js +367 -0
- package/src/code/modelCommand.js +199 -23
- package/src/code/nativeRunner.js +184 -20
- package/src/code/protocol/protocolValidator.js +3 -3
- package/src/code/providers/anthropicMessagesTransport.js +28 -1
- package/src/code/providers/index.js +2 -0
- package/src/code/providers/modelsCatalog.js +304 -0
- package/src/code/providers/openaiChatTransport.js +19 -1
- package/src/code/providers/visionBlocks.js +110 -0
- package/src/code/repl.js +37 -8
- package/src/code/runtime/taskControl.js +177 -53
- package/src/code/runtime/taskFocus.js +30 -10
- package/src/code/runtime/taskLoop.js +12 -1
- package/src/code/runtime/taskRun.js +10 -1
- package/src/code/thinkingLevels.js +132 -0
- package/src/code/tools/readImage.js +110 -0
- package/src/code/tools/taskRun.js +118 -0
- package/src/config.js +10 -1
- package/src/ui/format/index.js +103 -5
- package/src/ui/ink/ChatApp.js +137 -25
- package/src/ui/ink/MultilineInput.js +38 -2
- package/src/ui/ink/UcodeApp.js +102 -14
- package/src/ui/ink/chatLogModel.js +238 -32
- package/src/ui/ink/chatReducer.js +18 -6
package/src/code/nativeRunner.js
CHANGED
|
@@ -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([
|
|
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
|
|
82
|
-
//
|
|
83
|
-
// UFOO_UCODE_THINKING_BUDGET_TOKENS overrides
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
450
|
-
"
|
|
451
|
-
"
|
|
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
|
|
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 (
|
|
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
|
|
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 &&
|
|
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:
|
|
104
|
+
is_error: isError,
|
|
78
105
|
});
|
|
79
106
|
},
|
|
80
107
|
flushToolResults({ messages, collected }) {
|
|
@@ -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
|
+
};
|