@f5-sales-demo/xcsh 20.8.5 → 20.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "20.8.5",
4
+ "version": "20.8.6",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -61,13 +61,13 @@
61
61
  "dependencies": {
62
62
  "@agentclientprotocol/sdk": "1.3.0",
63
63
  "@mozilla/readability": "^0.6",
64
- "@f5-sales-demo/xcsh-stats": "20.8.5",
65
- "@f5-sales-demo/pi-agent-core": "20.8.5",
66
- "@f5-sales-demo/pi-ai": "20.8.5",
67
- "@f5-sales-demo/pi-natives": "20.8.5",
68
- "@f5-sales-demo/pi-resource-management": "20.8.5",
69
- "@f5-sales-demo/pi-tui": "20.8.5",
70
- "@f5-sales-demo/pi-utils": "20.8.5",
64
+ "@f5-sales-demo/xcsh-stats": "20.8.6",
65
+ "@f5-sales-demo/pi-agent-core": "20.8.6",
66
+ "@f5-sales-demo/pi-ai": "20.8.6",
67
+ "@f5-sales-demo/pi-natives": "20.8.6",
68
+ "@f5-sales-demo/pi-resource-management": "20.8.6",
69
+ "@f5-sales-demo/pi-tui": "20.8.6",
70
+ "@f5-sales-demo/pi-utils": "20.8.6",
71
71
  "@sinclair/typebox": "^0.34",
72
72
  "@xterm/headless": "^6.0",
73
73
  "ajv": "^8.20",
@@ -627,6 +627,28 @@ export const SETTINGS_SCHEMA = {
627
627
  default: [],
628
628
  },
629
629
 
630
+ "routing.internalOpenAiUrl": {
631
+ type: "string",
632
+ default: "",
633
+ ui: {
634
+ tab: "model",
635
+ label: "Internal OpenAI URL",
636
+ description: "Internal FQDN for OpenAI routing lane",
637
+ submenu: true,
638
+ },
639
+ },
640
+
641
+ "routing.internalAnthropicUrl": {
642
+ type: "string",
643
+ default: "",
644
+ ui: {
645
+ tab: "model",
646
+ label: "Internal Anthropic URL",
647
+ description: "Internal FQDN for Anthropic routing lane",
648
+ submenu: true,
649
+ },
650
+ },
651
+
630
652
  // Sampling
631
653
  temperature: {
632
654
  type: "number",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.8.5",
21
- "commit": "ed354637dc7586f58ebedbea98dea65b304e7122",
22
- "shortCommit": "ed35463",
20
+ "version": "20.8.6",
21
+ "commit": "ffac88b3a59886c6e6e579bc266e0a62e3d354d5",
22
+ "shortCommit": "ffac88b",
23
23
  "branch": "main",
24
- "tag": "v20.8.5",
25
- "commitDate": "2026-08-09T13:04:40Z",
26
- "buildDate": "2026-08-09T13:30:24.105Z",
24
+ "tag": "v20.8.6",
25
+ "commitDate": "2026-08-10T05:40:49Z",
26
+ "buildDate": "2026-08-10T06:09:48.444Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/ed354637dc7586f58ebedbea98dea65b304e7122",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.8.5"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/ffac88b3a59886c6e6e579bc266e0a62e3d354d5",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.8.6"
33
33
  };
@@ -107,4 +107,7 @@ export interface RoutingSettings {
107
107
  pools: Record<string, RoutingPoolConfig>;
108
108
  disabledPresets: string[];
109
109
  tierEffort?: Record<string, string>;
110
+
111
+ internalOpenAiUrl?: string;
112
+ internalAnthropicUrl?: string;
110
113
  }
@@ -1354,6 +1354,7 @@ export class AgentSession {
1354
1354
 
1355
1355
  const previousTier = this.#routingCoordinator.getState().currentTier;
1356
1356
  const previousFloor = this.#routingCoordinator.getState().escalationFloor;
1357
+ const previousModel = this.model;
1357
1358
 
1358
1359
  try {
1359
1360
  this.agent.abort();
@@ -1387,6 +1388,13 @@ export class AgentSession {
1387
1388
  } else {
1388
1389
  this.#routingCoordinator.getStateMachine().clearEscalationFloor();
1389
1390
  }
1391
+ if (previousModel) {
1392
+ this.#setModelWithProviderSessionReset(previousModel, "runtime-switch");
1393
+ this.sessionManager.appendModelChange(
1394
+ `${previousModel.provider}/${previousModel.id}`,
1395
+ "routing_switch_rollback",
1396
+ );
1397
+ }
1390
1398
  } finally {
1391
1399
  this.#scheduleAgentContinue({ delayMs: 10 });
1392
1400
  }
@@ -2850,13 +2858,30 @@ export class AgentSession {
2850
2858
  .getAll()
2851
2859
  .find(m => `${m.provider}/${m.id}` === utilityModel || m.id === utilityModel);
2852
2860
  if (!resolved) throw new Error("Classifier model not found");
2853
- const apiKey = await this.#modelRegistry.getApiKey(resolved, this.sessionId);
2861
+
2862
+ let targetModel = resolved;
2863
+ if (
2864
+ resolved.provider === "openai" ||
2865
+ (resolved.provider === "litellm" && resolved.id.includes("openai"))
2866
+ ) {
2867
+ const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
2868
+ if (internalUrl) targetModel = { ...resolved, baseUrl: internalUrl };
2869
+ } else if (
2870
+ resolved.provider === "anthropic" ||
2871
+ (resolved.provider === "litellm" &&
2872
+ (resolved.id.includes("anthropic") || resolved.id.includes("claude")))
2873
+ ) {
2874
+ const internalUrl = this.settings.get("routing.internalAnthropicUrl") as string | undefined;
2875
+ if (internalUrl) targetModel = { ...resolved, baseUrl: internalUrl };
2876
+ }
2877
+
2878
+ const apiKey = await this.#modelRegistry.getApiKey(targetModel, this.sessionId);
2854
2879
  if (!apiKey) throw new Error("No API key for classifier model");
2855
2880
 
2856
2881
  const systemInstruction = `You are a routing classifier. Analyze the user's prompt and output a JSON object: { "complexityScore": number, "confidence": number, "delegation"?: { "reason": string, "subtasks": { "id": string, "title": string, "description": string, "targetFilesOrPaths": string[], "desiredTier"?: string }[] } }. Score complexity from 0 to 100 based on required reasoning, context width, and coding effort. Return ONLY valid JSON without markdown blocks.`;
2857
2882
 
2858
2883
  const res = await completeSimple(
2859
- resolved,
2884
+ targetModel,
2860
2885
  {
2861
2886
  systemPrompt: systemInstruction,
2862
2887
  messages: [
@@ -3080,17 +3105,270 @@ export class AgentSession {
3080
3105
  return;
3081
3106
  }
3082
3107
 
3108
+ const clonedContent = Array.isArray(message.content)
3109
+ ? message.content.map((c: any) => ({ ...c }))
3110
+ : message.content;
3111
+
3083
3112
  const customMessage: CustomMessage<T> = {
3084
3113
  role: "custom",
3085
3114
  customType: message.customType,
3086
- content: message.content,
3115
+ content: clonedContent,
3087
3116
  display: message.display,
3088
3117
  details: message.details,
3089
3118
  attribution: message.attribution ?? "agent",
3090
3119
  timestamp: Date.now(),
3091
3120
  };
3092
3121
 
3093
- await this.#promptWithMessage(customMessage, textContent, options);
3122
+ const delegationOutput = await this.#evaluateAndApplyRouting(
3123
+ textContent,
3124
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
3125
+ );
3126
+ if (delegationOutput) {
3127
+ if (typeof customMessage.content === "string") {
3128
+ customMessage.content += delegationOutput;
3129
+ } else if (Array.isArray(customMessage.content)) {
3130
+ const textBlock = customMessage.content.find((c: any) => c.type === "text") as any;
3131
+ if (textBlock) {
3132
+ textBlock.text += delegationOutput;
3133
+ } else {
3134
+ customMessage.content.push({ type: "text", text: delegationOutput });
3135
+ }
3136
+ }
3137
+ }
3138
+
3139
+ await this.#promptWithMessage(customMessage, textContent + (delegationOutput || ""), options);
3140
+ }
3141
+
3142
+ async #evaluateAndApplyRouting(
3143
+ promptText: string,
3144
+ hasImages: boolean,
3145
+ options?: { signal?: AbortSignal } | any,
3146
+ ): Promise<string | undefined> {
3147
+ const routingMode = (this.settings.get("routing.mode") as import("../routing/types").RoutingMode) ?? "off";
3148
+ if (routingMode === "off" || !this.model) return undefined;
3149
+
3150
+ if (this.#activeRetryFallback) {
3151
+ this.#emitSessionEvent({
3152
+ type: "routing_skipped",
3153
+ epochId: `skip-${Date.now()}`,
3154
+ reasons: ["retry_fallback"],
3155
+ } as any).catch(() => {});
3156
+ return undefined;
3157
+ }
3158
+
3159
+ const anchorModel = `${this.model.provider}/${this.model.id}`;
3160
+ const availableModels =
3161
+ this.#scopedModels.length > 0
3162
+ ? this.#scopedModels.map(sm => `${sm.model.provider}/${sm.model.id}`)
3163
+ : this.#modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`);
3164
+ const startTime = Date.now();
3165
+ const systemTokens = Math.round((this.systemPrompt?.length ?? 0) / 4);
3166
+ const promptTokens = Math.round(promptText.length / 4);
3167
+ const toolsStr = JSON.stringify(Array.from(this.#toolRegistry.values()));
3168
+ const toolsTokens = Math.round(toolsStr.length / 4);
3169
+ const reserveTokens = (this.settings.get("compaction.reserveTokens") as number) ?? 0;
3170
+ const contextEstimate = {
3171
+ usedTokens: calculateUsedTokens(this.messages) + systemTokens + promptTokens + toolsTokens,
3172
+ reserveTokens,
3173
+ contextWindow: this.model.contextWindow ?? 128000,
3174
+ };
3175
+ const decision = await this.#routingCoordinator.evaluateTurn({
3176
+ anchorModel,
3177
+ mode: routingMode,
3178
+ prompt: promptText,
3179
+ hasImages,
3180
+ priorRejection: this.#routingCoordinator.getState().escalationFloor !== undefined,
3181
+ availableModels,
3182
+ customPools: validateCustomPools(this.settings.get("routing.pools")),
3183
+ disabledPresets: (this.settings.get("routing.disabledPresets") as readonly string[]) ?? [],
3184
+ familyPolicy: (this.settings.get("routing.familyPolicy") as "sticky" | "configured-mixed") ?? "sticky",
3185
+ profilerMode: (this.settings.get("routing.profiler") as any) ?? "hybrid",
3186
+ contextEstimate,
3187
+ getModelContextWindow: (modelId: string) => {
3188
+ const m = this.#modelRegistry
3189
+ .getAvailable()
3190
+ .find(m => `${m.provider}/${m.id}` === modelId || m.id === modelId);
3191
+ return m?.contextWindow ?? 128000;
3192
+ },
3193
+ });
3194
+
3195
+ this.#emitSessionEvent({
3196
+ type: "routing_evaluated",
3197
+ epochId: decision.epochId,
3198
+ profiler: {
3199
+ latencyMs: Date.now() - startTime,
3200
+ ...(decision as any).profiler,
3201
+ },
3202
+ applied: decision.applied,
3203
+ decision: decision.selectedModel,
3204
+ delegation: decision.delegation,
3205
+ } as any).catch(() => {});
3206
+
3207
+ if (decision.selectedModel && decision.applied) {
3208
+ const targetModel = this.#modelRegistry
3209
+ .getAvailable()
3210
+ .find(m => `${m.provider}/${m.id}` === decision.selectedModel || m.id === decision.selectedModel);
3211
+ if (targetModel) {
3212
+ const currentModelId = `${this.model.provider}/${this.model.id}`;
3213
+ const targetModelId = `${targetModel.provider}/${targetModel.id}`;
3214
+
3215
+ let needsSwitch = currentModelId !== targetModelId;
3216
+
3217
+ // Force switch if the current model is the same but needs an internal URL override
3218
+ if (!needsSwitch) {
3219
+ if (
3220
+ targetModel.provider === "openai" ||
3221
+ (targetModel.provider === "litellm" && targetModel.id.includes("openai"))
3222
+ ) {
3223
+ const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
3224
+ if ((internalUrl || undefined) !== this.model.baseUrl) {
3225
+ needsSwitch = true;
3226
+ }
3227
+ } else if (
3228
+ targetModel.provider === "anthropic" ||
3229
+ (targetModel.provider === "litellm" &&
3230
+ (targetModel.id.includes("anthropic") || targetModel.id.includes("claude")))
3231
+ ) {
3232
+ const internalUrl = this.settings.get("routing.internalAnthropicUrl") as string | undefined;
3233
+ if ((internalUrl || undefined) !== this.model.baseUrl) {
3234
+ needsSwitch = true;
3235
+ }
3236
+ }
3237
+ }
3238
+
3239
+ if (needsSwitch) {
3240
+ await this.setModelRoutingSwitch(targetModel);
3241
+ }
3242
+ }
3243
+ }
3244
+
3245
+ if (
3246
+ decision.applied &&
3247
+ decision.delegation &&
3248
+ decision.delegation.subtasks.length > 1 &&
3249
+ (this.settings.get("routing.delegation") as string) === "read-only"
3250
+ ) {
3251
+ let maxTasks = (this.settings.get("routing.delegationMaxTasks") as number) ?? 3;
3252
+ maxTasks = Math.min(Math.max(1, maxTasks), 3);
3253
+
3254
+ const { results, tokensUsed } = await executeReadOnlyDelegationPlan(
3255
+ decision.delegation,
3256
+ async (subtaskPrompt, signal) => {
3257
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3258
+
3259
+ const { resolveModelPool } = await import("../routing/presets");
3260
+ const pool = resolveModelPool(
3261
+ decision.poolId ?? (this.model ? `${this.model.provider}/${this.model.id}` : ""),
3262
+ this.settings.get("routing.pools") as any,
3263
+ this.settings.get("routing.disabledPresets") as readonly string[],
3264
+ this.settings.get("routing.familyPolicy") as any,
3265
+ );
3266
+
3267
+ let resolvedUtility = this.model;
3268
+ if (pool?.tiers?.utility) {
3269
+ let uId = pool.tiers.utility;
3270
+ if (!uId.includes("/") && pool.provider) {
3271
+ uId = `${pool.provider}/${uId}`;
3272
+ }
3273
+ const found = this.#modelRegistry
3274
+ .getAvailable()
3275
+ .find(m => `${m.provider}/${m.id}` === uId || m.id === uId);
3276
+ if (found) resolvedUtility = found;
3277
+ }
3278
+
3279
+ if (!resolvedUtility) return { result: "Delegation failed: Model not resolved", tokens: 0 };
3280
+
3281
+ if (
3282
+ resolvedUtility.provider === "openai" ||
3283
+ (resolvedUtility.provider === "litellm" && resolvedUtility.id.includes("openai"))
3284
+ ) {
3285
+ const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
3286
+ if (internalUrl) resolvedUtility = { ...resolvedUtility, baseUrl: internalUrl } as any;
3287
+ } else if (
3288
+ resolvedUtility.provider === "anthropic" ||
3289
+ (resolvedUtility.provider === "litellm" &&
3290
+ (resolvedUtility.id.includes("anthropic") || resolvedUtility.id.includes("claude")))
3291
+ ) {
3292
+ const internalUrl = this.settings.get("routing.internalAnthropicUrl") as string | undefined;
3293
+ if (internalUrl) resolvedUtility = { ...resolvedUtility, baseUrl: internalUrl } as any;
3294
+ }
3295
+
3296
+ const { createAgentSession } = await import("../sdk");
3297
+ const { SessionManager: SDKSessionManager } = await import("./session-manager");
3298
+ const childSettings = await this.settings.cloneForCwd(process.cwd());
3299
+ childSettings.set("routing.delegation", "off");
3300
+
3301
+ if (options?.signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3302
+
3303
+ const { session: childSession } = await createAgentSession({
3304
+ model: resolvedUtility!,
3305
+ sessionManager: SDKSessionManager.inMemory(),
3306
+ settings: childSettings,
3307
+ authStorage: this.#modelRegistry.authStorage,
3308
+ modelRegistry: this.#modelRegistry,
3309
+ toolNames: Array.from(this.#toolRegistry.values())
3310
+ .map(t => t.name)
3311
+ .filter(isDelegationAllowedTool),
3312
+ enableLsp: false,
3313
+ enableMCP: false,
3314
+ });
3315
+
3316
+ try {
3317
+ const onAbort = () => childSession.agent.abort();
3318
+ signal?.addEventListener("abort", onAbort);
3319
+ if (signal?.aborted) {
3320
+ onAbort();
3321
+ return { result: "Delegation failed: Aborted", tokens: 0 };
3322
+ }
3323
+ try {
3324
+ await childSession.prompt(subtaskPrompt);
3325
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3326
+ const resultStr = childSession
3327
+ .buildDisplaySessionContext()
3328
+ .messages.filter((m: any) => m.role === "assistant")
3329
+ .map((m: any) =>
3330
+ m.content
3331
+ .filter((c: any) => c.type === "text")
3332
+ .map((c: any) => c.text)
3333
+ .join(""),
3334
+ )
3335
+ .join("\n");
3336
+ const tUsage = ((childSession as any).agent?.lastUsage?.totalTokens ?? 0) as number;
3337
+ return { result: resultStr || "No output", tokens: tUsage };
3338
+ } finally {
3339
+ signal?.removeEventListener("abort", onAbort);
3340
+ }
3341
+ } catch (err) {
3342
+ if (signal?.aborted) return { result: "Delegation failed: Aborted", tokens: 0 };
3343
+ return { result: `Delegation failed: ${String(err)}`, tokens: 0 };
3344
+ } finally {
3345
+ await childSession.dispose();
3346
+ }
3347
+ },
3348
+ maxTasks,
3349
+ { signal: options?.signal },
3350
+ );
3351
+
3352
+ if (results.length > 0) {
3353
+ let resultsString = JSON.stringify(results, null, 2);
3354
+ if (resultsString.length > 8000) {
3355
+ resultsString = `${resultsString.substring(0, 8000)}\n...[truncated]`;
3356
+ }
3357
+ const delegationOutput = `\n\n<delegation_results>\n${resultsString}\n</delegation_results>`;
3358
+
3359
+ this.#emitSessionEvent({
3360
+ type: "routing_delegated",
3361
+ epochId: decision.epochId,
3362
+ tasks: decision.delegation.subtasks.length,
3363
+ completed: results.length,
3364
+ tokensUsed,
3365
+ } as any).catch(() => {});
3366
+
3367
+ return delegationOutput;
3368
+ }
3369
+ }
3370
+
3371
+ return undefined;
3094
3372
  }
3095
3373
 
3096
3374
  async #promptWithMessage(
@@ -3479,8 +3757,25 @@ export class AgentSession {
3479
3757
 
3480
3758
  const prependMessages = queuedMessages.slice(0, -1);
3481
3759
  const textContent = this.#getCustomMessageTextContent(message);
3760
+ const delegationOutput = await this.#evaluateAndApplyRouting(
3761
+ textContent,
3762
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
3763
+ );
3764
+ if (delegationOutput) {
3765
+ if (typeof message.content === "string") {
3766
+ message.content += delegationOutput;
3767
+ } else if (Array.isArray(message.content)) {
3768
+ const textBlock = message.content.find((c: any) => c.type === "text");
3769
+ if (textBlock) {
3770
+ (textBlock as any).text += delegationOutput;
3771
+ } else {
3772
+ message.content.push({ type: "text", text: delegationOutput });
3773
+ }
3774
+ }
3775
+ }
3776
+
3482
3777
  try {
3483
- await this.#promptWithMessage(message, textContent, {
3778
+ await this.#promptWithMessage(message, textContent + (delegationOutput || ""), {
3484
3779
  prependMessages,
3485
3780
  skipPostPromptRecoveryWait: true,
3486
3781
  });
@@ -3529,10 +3824,14 @@ export class AgentSession {
3529
3824
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
3530
3825
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
3531
3826
  ): Promise<void> {
3827
+ const clonedContent = Array.isArray(message.content)
3828
+ ? message.content.map((c: any) => ({ ...c }))
3829
+ : message.content;
3830
+
3532
3831
  const appMessage: CustomMessage<T> = {
3533
3832
  role: "custom",
3534
3833
  customType: message.customType,
3535
- content: message.content,
3834
+ content: clonedContent,
3536
3835
  display: message.display,
3537
3836
  details: message.details,
3538
3837
  attribution: message.attribution ?? "agent",
@@ -3554,7 +3853,33 @@ export class AgentSession {
3554
3853
 
3555
3854
  if (options?.deliverAs === "nextTurn") {
3556
3855
  if (options?.triggerTurn) {
3557
- await this.agent.prompt(appMessage);
3856
+ let promptText = "";
3857
+ if (typeof message.content === "string") promptText = message.content;
3858
+ else if (Array.isArray(message.content)) {
3859
+ promptText = message.content
3860
+ .filter(c => c.type === "text")
3861
+ .map((c: any) => c.text)
3862
+ .join("");
3863
+ }
3864
+ const delegationOutput = await this.#evaluateAndApplyRouting(
3865
+ promptText,
3866
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
3867
+ options,
3868
+ );
3869
+ if (delegationOutput) {
3870
+ promptText += delegationOutput;
3871
+ if (typeof appMessage.content === "string") {
3872
+ appMessage.content += delegationOutput;
3873
+ } else if (Array.isArray(appMessage.content)) {
3874
+ const textBlock = appMessage.content.find(c => c.type === "text") as any;
3875
+ if (textBlock) {
3876
+ textBlock.text += delegationOutput;
3877
+ } else {
3878
+ appMessage.content.push({ type: "text", text: delegationOutput });
3879
+ }
3880
+ }
3881
+ }
3882
+ await this.#promptWithMessage(appMessage, promptText, options as any);
3558
3883
  return;
3559
3884
  }
3560
3885
  this.agent.appendMessage(appMessage);
@@ -3569,7 +3894,33 @@ export class AgentSession {
3569
3894
  }
3570
3895
 
3571
3896
  if (options?.triggerTurn) {
3572
- await this.agent.prompt(appMessage);
3897
+ let promptText = "";
3898
+ if (typeof message.content === "string") promptText = message.content;
3899
+ else if (Array.isArray(message.content)) {
3900
+ promptText = message.content
3901
+ .filter(c => c.type === "text")
3902
+ .map((c: any) => c.text)
3903
+ .join("");
3904
+ }
3905
+ const delegationOutput = await this.#evaluateAndApplyRouting(
3906
+ promptText,
3907
+ Array.isArray(message.content) && message.content.some((c: any) => c.type === "image"),
3908
+ options,
3909
+ );
3910
+ if (delegationOutput) {
3911
+ promptText += delegationOutput;
3912
+ if (typeof appMessage.content === "string") {
3913
+ appMessage.content += delegationOutput;
3914
+ } else if (Array.isArray(appMessage.content)) {
3915
+ const textBlock = appMessage.content.find(c => c.type === "text") as any;
3916
+ if (textBlock) {
3917
+ textBlock.text += delegationOutput;
3918
+ } else {
3919
+ appMessage.content.push({ type: "text", text: delegationOutput });
3920
+ }
3921
+ }
3922
+ }
3923
+ await this.#promptWithMessage(appMessage, promptText, options as any);
3573
3924
  return;
3574
3925
  }
3575
3926
 
@@ -4014,15 +4365,28 @@ export class AgentSession {
4014
4365
  */
4015
4366
  async setModelRoutingSwitch(model: Model): Promise<void> {
4016
4367
  const previousEditMode = this.#resolveActiveEditMode();
4017
- const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
4368
+
4369
+ let targetModel = model;
4370
+ if (model.provider === "openai" || (model.provider === "litellm" && model.id.includes("openai"))) {
4371
+ const internalUrl = this.settings.get("routing.internalOpenAiUrl") as string | undefined;
4372
+ if (internalUrl) targetModel = { ...model, baseUrl: internalUrl };
4373
+ } else if (
4374
+ model.provider === "anthropic" ||
4375
+ (model.provider === "litellm" && (model.id.includes("anthropic") || model.id.includes("claude")))
4376
+ ) {
4377
+ const internalUrl = this.settings.get("routing.internalAnthropicUrl") as string | undefined;
4378
+ if (internalUrl) targetModel = { ...model, baseUrl: internalUrl };
4379
+ }
4380
+
4381
+ const apiKey = await this.#modelRegistry.getApiKey(targetModel, this.sessionId);
4018
4382
  if (!apiKey) {
4019
- throw new Error(`No API key for ${model.provider}/${model.id}`);
4383
+ throw new Error(`No API key for ${targetModel.provider}/${targetModel.id}`);
4020
4384
  }
4021
4385
 
4022
4386
  // DO NOT clear active retry fallback - routing is a transient optimization
4023
- this.#setModelWithProviderSessionReset(model, "runtime-switch");
4024
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "routing_switch");
4025
- this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
4387
+ this.#setModelWithProviderSessionReset(targetModel, "runtime-switch");
4388
+ this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, "routing_switch");
4389
+ this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
4026
4390
 
4027
4391
  this.setThinkingLevel(this.thinkingLevel);
4028
4392
  await this.#syncEditToolModeAfterModelChange(previousEditMode);