@oh-my-pi/pi-catalog 16.1.23 → 16.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.
@@ -55,7 +55,7 @@ export function allowsUnauthenticatedCatalogDiscovery(descriptor: CatalogProvide
55
55
  * - Every entry is a member of `KnownProvider`.
56
56
  * - `createModelManagerOptions` present (and not `specialModelManager`) ⇒
57
57
  * appears in `PROVIDER_DESCRIPTORS` for runtime model discovery.
58
- * - `catalogDiscovery` present ⇒ participates in `generate-models.ts`.
58
+ * - \`catalogDiscovery\` present ⇒ participates in \`generate-models.ts\`.
59
59
  */
60
60
  export interface ProviderCatalogEntry {
61
61
  readonly id: string;
@@ -50,7 +50,12 @@ import {
50
50
  zenmuxModelManagerOptions,
51
51
  zhipuCodingPlanModelManagerOptions,
52
52
  } from "./openai-compat";
53
- import { cursorModelManagerOptions, devinModelManagerOptions, zaiModelManagerOptions } from "./special";
53
+ import {
54
+ cursorModelManagerOptions,
55
+ devinModelManagerOptions,
56
+ gitLabDuoWorkflowModelManagerOptions,
57
+ zaiModelManagerOptions,
58
+ } from "./special";
54
59
 
55
60
  export const CATALOG_PROVIDERS = [
56
61
  {
@@ -142,6 +147,13 @@ export const CATALOG_PROVIDERS = [
142
147
  defaultModel: "duo-chat-opus-4-6",
143
148
  envVars: ["GITLAB_TOKEN"],
144
149
  },
150
+ {
151
+ id: "gitlab-duo-agent",
152
+ defaultModel: "claude_sonnet_4_6_vertex",
153
+ envVars: ["GITLAB_TOKEN"],
154
+ createModelManagerOptions: (config: ModelManagerConfig) => gitLabDuoWorkflowModelManagerOptions(config),
155
+ dynamicModelsAuthoritative: true,
156
+ },
145
157
  {
146
158
  id: "google",
147
159
  defaultModel: "gemini-3.1-pro-preview",
@@ -8,7 +8,7 @@ import { FIREWORKS_FAST_SUFFIX, toFireworksPublicModelId } from "../fireworks-mo
8
8
  import { isGlmVisionModelId, isGrokReasoningEffortCapable, isReasoningGlmModelId } from "../identity/family";
9
9
  import type { ModelManagerOptions } from "../model-manager";
10
10
  import { getBundledModels } from "../models";
11
- import type { Api, FetchImpl, Model, ModelSpec, Provider, ThinkingConfig } from "../types";
11
+ import type { Api, FetchImpl, Model, ModelSpec, OpenAICompat, Provider, ThinkingConfig } from "../types";
12
12
  import { isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils";
13
13
  import { coreWeaveProjectHeaders } from "../wire/coreweave";
14
14
  import {
@@ -1066,7 +1066,7 @@ function applyXAIOAuthCuration(dynamic: readonly ModelSpec<"openai-responses">[]
1066
1066
  * Single source of truth for the curated to Model fan-in, consumed by both
1067
1067
  * - {@link xaiOAuthModelManagerOptions} (runtime static seed handed to the model
1068
1068
  * manager so the picker is populated on a fresh login), and
1069
- * - `packages/ai/scripts/generate-models.ts` (bundles the same entries into
1069
+ * - \`packages/catalog/scripts/generate-models.ts\` (bundles the same entries into
1070
1070
  * `models.json`, so the synchronous `ModelRegistry.#loadModels()` boot path
1071
1071
  * sees `xai-oauth` without waiting for a refresh — fixes the boot-time
1072
1072
  * default-model reset when `modelRoles.default = "xai-oauth/<id>"`).
@@ -1115,7 +1115,7 @@ export function xaiOAuthModelManagerOptions(
1115
1115
  // Static seed handed to the runtime model manager so the picker populates on
1116
1116
  // a fresh login even before `fetchDynamicModels` fires (it is gated on
1117
1117
  // `config.apiKey` at construction time, and OAuth tokens resolve later via
1118
- // AuthStorage). `generate-models.ts` calls the same builder so `models.json`
1118
+ // AuthStorage). \`generate-models.ts\` calls the same builder so \`models.json\`
1119
1119
  // carries these entries too — making the synchronous `#loadModels()` boot
1120
1120
  // path honor `modelRoles.default = "xai-oauth/<id>"` without `await refresh()`.
1121
1121
  const staticModels = buildXaiOAuthStaticSeed(resolvedBaseUrl);
@@ -2817,6 +2817,231 @@ export interface LiteLLMModelManagerConfig {
2817
2817
  fetch?: FetchImpl;
2818
2818
  }
2819
2819
 
2820
+ export interface FetchLiteLLMRichModelsOptions<TApi extends Api> {
2821
+ api: TApi;
2822
+ provider: Provider;
2823
+ baseUrl: string;
2824
+ apiKey?: string;
2825
+ headers?: Record<string, string>;
2826
+ fetch?: FetchImpl;
2827
+ signal?: AbortSignal;
2828
+ referenceResolver?: (modelId: string) => ModelSpec<TApi> | undefined;
2829
+ }
2830
+
2831
+ type LiteLLMRichModelEntry = Record<string, unknown>;
2832
+
2833
+ const LITELLM_RICH_ENDPOINTS = ["/model_group/info", "/v2/model/info", "/model/info", "/v1/model/info"] as const;
2834
+ export const OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW = 128_000;
2835
+ export const OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS = 32_768;
2836
+ const UNKNOWN_PROXY_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const;
2837
+
2838
+ export function normalizeLiteLLMManagementBaseUrl(baseUrl: string): string {
2839
+ const trimmed = baseUrl.trim().replace(/\/+$/g, "");
2840
+ if (!trimmed) {
2841
+ return "";
2842
+ }
2843
+ try {
2844
+ const parsed = new URL(trimmed);
2845
+ const path = parsed.pathname.replace(/\/+$/g, "");
2846
+ parsed.pathname = path.endsWith("/v1") ? path.slice(0, -3) || "/" : path || "/";
2847
+ const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
2848
+ return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
2849
+ } catch {
2850
+ return trimmed.replace(/\/v1$/, "");
2851
+ }
2852
+ }
2853
+
2854
+ function normalizeLiteLLMRuntimeBaseUrl(baseUrl: string): string {
2855
+ const trimmed = baseUrl.trim();
2856
+ return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
2857
+ }
2858
+
2859
+ function toNonEmptyString(value: unknown): string | undefined {
2860
+ if (typeof value !== "string") {
2861
+ return undefined;
2862
+ }
2863
+ const trimmed = value.trim();
2864
+ return trimmed.length > 0 ? trimmed : undefined;
2865
+ }
2866
+
2867
+ function extractLiteLLMRichEntries(payload: unknown): LiteLLMRichModelEntry[] | null {
2868
+ if (Array.isArray(payload)) {
2869
+ return payload.flatMap(entry => (isRecord(entry) ? [entry] : []));
2870
+ }
2871
+ if (!isRecord(payload)) {
2872
+ return null;
2873
+ }
2874
+ for (const candidate of [payload.data, payload.models, payload.result, payload.items]) {
2875
+ if (candidate === undefined) {
2876
+ continue;
2877
+ }
2878
+ const entries = extractLiteLLMRichEntries(candidate);
2879
+ if (entries !== null) {
2880
+ return entries;
2881
+ }
2882
+ }
2883
+ return null;
2884
+ }
2885
+
2886
+ function getLiteLLMModelInfo(entry: LiteLLMRichModelEntry): LiteLLMRichModelEntry | undefined {
2887
+ return isRecord(entry.model_info) ? entry.model_info : undefined;
2888
+ }
2889
+
2890
+ function getLiteLLMParams(entry: LiteLLMRichModelEntry): LiteLLMRichModelEntry | undefined {
2891
+ return isRecord(entry.litellm_params) ? entry.litellm_params : undefined;
2892
+ }
2893
+
2894
+ function getLiteLLMMetadataValue(entry: LiteLLMRichModelEntry, key: string): unknown {
2895
+ return entry[key] ?? getLiteLLMModelInfo(entry)?.[key];
2896
+ }
2897
+
2898
+ function getLiteLLMRichModelId(entry: LiteLLMRichModelEntry): string | undefined {
2899
+ return (
2900
+ toNonEmptyString(entry.model_group) ??
2901
+ toNonEmptyString(entry.model_name) ??
2902
+ toNonEmptyString(entry.id) ??
2903
+ toNonEmptyString(getLiteLLMParams(entry)?.model)
2904
+ );
2905
+ }
2906
+
2907
+ function getSupportedOpenAIParams(entry: LiteLLMRichModelEntry): string[] | undefined {
2908
+ const value = getLiteLLMMetadataValue(entry, "supported_openai_params");
2909
+ if (!Array.isArray(value)) {
2910
+ return undefined;
2911
+ }
2912
+ return value.flatMap(item => (typeof item === "string" ? [item] : []));
2913
+ }
2914
+
2915
+ function mapLiteLLMRichEntry<TApi extends Api>(
2916
+ entry: LiteLLMRichModelEntry,
2917
+ options: FetchLiteLLMRichModelsOptions<TApi>,
2918
+ runtimeBaseUrl: string,
2919
+ ): ModelSpec<TApi> | null {
2920
+ const id = getLiteLLMRichModelId(entry);
2921
+ if (!id) {
2922
+ return null;
2923
+ }
2924
+ const reference = options.referenceResolver?.(id);
2925
+ const modelName = toNonEmptyString(entry.model_name);
2926
+ const contextWindow = toPositiveNumber(
2927
+ getLiteLLMMetadataValue(entry, "max_input_tokens"),
2928
+ reference?.contextWindow ?? OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW,
2929
+ );
2930
+ const maxTokens = toPositiveNumber(
2931
+ getLiteLLMMetadataValue(entry, "max_output_tokens"),
2932
+ reference?.maxTokens ?? Math.min(contextWindow, OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS),
2933
+ );
2934
+ const supportsVision = getLiteLLMMetadataValue(entry, "supports_vision");
2935
+ const supportsReasoning = getLiteLLMMetadataValue(entry, "supports_reasoning");
2936
+ const supportedOpenAIParams = getSupportedOpenAIParams(entry);
2937
+ const supportsFunctionCalling = getLiteLLMMetadataValue(entry, "supports_function_calling");
2938
+ const supportsTools =
2939
+ supportsFunctionCalling === true
2940
+ ? true
2941
+ : supportsFunctionCalling === false
2942
+ ? false
2943
+ : supportedOpenAIParams !== undefined
2944
+ ? supportedOpenAIParams.some(param =>
2945
+ ["tools", "tool_choice", "functions", "function_call"].includes(param),
2946
+ )
2947
+ : reference?.supportsTools;
2948
+ const compat: OpenAICompat = {
2949
+ ...(reference?.compat ?? {}),
2950
+ supportsStore: false,
2951
+ supportsDeveloperRole: false,
2952
+ ...(supportedOpenAIParams !== undefined
2953
+ ? { supportsReasoningEffort: supportedOpenAIParams.includes("reasoning_effort") }
2954
+ : {}),
2955
+ };
2956
+ return {
2957
+ id,
2958
+ name: modelName && modelName !== id ? modelName : (reference?.name ?? id),
2959
+ api: options.api,
2960
+ provider: options.provider,
2961
+ baseUrl: runtimeBaseUrl,
2962
+ contextWindow,
2963
+ maxTokens,
2964
+ input:
2965
+ supportsVision === true
2966
+ ? ["text", "image"]
2967
+ : supportsVision === false
2968
+ ? ["text"]
2969
+ : (reference?.input ?? ["text"]),
2970
+ reasoning: typeof supportsReasoning === "boolean" ? supportsReasoning : (reference?.reasoning ?? false),
2971
+ thinking: reference?.thinking,
2972
+ cost: reference?.cost ?? UNKNOWN_PROXY_COST,
2973
+ ...(supportsTools !== undefined ? { supportsTools } : {}),
2974
+ compat: compat as ModelSpec<TApi>["compat"],
2975
+ };
2976
+ }
2977
+
2978
+ async function fetchLiteLLMRichEndpoint<TApi extends Api>(
2979
+ endpoint: string,
2980
+ options: FetchLiteLLMRichModelsOptions<TApi>,
2981
+ managementBaseUrl: string,
2982
+ runtimeBaseUrl: string,
2983
+ ): Promise<ModelSpec<TApi>[] | null> {
2984
+ const fetchImpl = options.fetch ?? globalThis.fetch;
2985
+ const requestHeaders: Record<string, string> = {
2986
+ Accept: "application/json",
2987
+ ...options.headers,
2988
+ };
2989
+ if (options.apiKey) {
2990
+ requestHeaders.Authorization = `Bearer ${options.apiKey}`;
2991
+ }
2992
+ let response: Response;
2993
+ try {
2994
+ response = await fetchImpl(`${managementBaseUrl}${endpoint}`, {
2995
+ method: "GET",
2996
+ headers: requestHeaders,
2997
+ signal: options.signal,
2998
+ });
2999
+ } catch {
3000
+ return null;
3001
+ }
3002
+ if (!response.ok) {
3003
+ return null;
3004
+ }
3005
+ let payload: unknown;
3006
+ try {
3007
+ payload = await response.json();
3008
+ } catch {
3009
+ return null;
3010
+ }
3011
+ const entries = extractLiteLLMRichEntries(payload);
3012
+ if (!entries || entries.length === 0) {
3013
+ return null;
3014
+ }
3015
+ const deduped = new Map<string, ModelSpec<TApi>>();
3016
+ for (const entry of entries) {
3017
+ const model = mapLiteLLMRichEntry(entry, options, runtimeBaseUrl);
3018
+ if (model) {
3019
+ deduped.set(model.id, model);
3020
+ }
3021
+ }
3022
+ if (deduped.size === 0) {
3023
+ return null;
3024
+ }
3025
+ return Array.from(deduped.values()).sort((left, right) => left.id.localeCompare(right.id));
3026
+ }
3027
+
3028
+ export async function fetchLiteLLMRichModels<TApi extends Api>(
3029
+ options: FetchLiteLLMRichModelsOptions<TApi>,
3030
+ ): Promise<ModelSpec<TApi>[] | null> {
3031
+ const managementBaseUrl = normalizeLiteLLMManagementBaseUrl(options.baseUrl);
3032
+ const runtimeBaseUrl = normalizeLiteLLMRuntimeBaseUrl(options.baseUrl);
3033
+ if (!managementBaseUrl || !runtimeBaseUrl) {
3034
+ return null;
3035
+ }
3036
+ for (const endpoint of LITELLM_RICH_ENDPOINTS) {
3037
+ const models = await fetchLiteLLMRichEndpoint(endpoint, options, managementBaseUrl, runtimeBaseUrl);
3038
+ if (models) {
3039
+ return models;
3040
+ }
3041
+ }
3042
+ return null;
3043
+ }
3044
+
2820
3045
  export function litellmModelManagerOptions(
2821
3046
  config?: LiteLLMModelManagerConfig,
2822
3047
  ): ModelManagerOptions<"openai-completions"> {
@@ -2824,21 +3049,32 @@ export function litellmModelManagerOptions(
2824
3049
  const baseUrl = config?.baseUrl ?? Bun.env.LITELLM_BASE_URL ?? "http://localhost:4000/v1";
2825
3050
  return {
2826
3051
  providerId: "litellm",
2827
- cacheProviderId: `litellm:${Bun.hash(baseUrl).toString(36)}`,
2828
- // litellm is a local-only proxy whose /v1/models returns bare ids with no
2829
- // metadata, and it is never bundled in models.json (that would leak the
2830
- // machine's localhost catalog). It proxies known upstream models, so we
2831
- // enrich discovered ids against models.dev the same reference source the
2832
- // gateway providers (fireworks et al.) use — instead of a bundled map.
3052
+ cacheProviderId: `litellm:rich-v1:${Bun.hash(baseUrl).toString(36)}`,
3053
+ // litellm is a local-only proxy and is never bundled in models.json (that
3054
+ // would leak the machine's localhost catalog). Prefer the proxy's richer
3055
+ // management metadata, then fall back to /v1/models and enrich bare ids
3056
+ // against models.dev like the gateway providers (fireworks et al.) do.
2833
3057
  fetchDynamicModels: async () => {
2834
3058
  const modelsDevReferences = await loadModelsDevReferences<"openai-completions">(config?.fetch);
3059
+ const resolveReference = (id: string) => modelsDevReferences.get(id);
3060
+ const richModels = await fetchLiteLLMRichModels({
3061
+ api: "openai-completions",
3062
+ provider: "litellm",
3063
+ baseUrl,
3064
+ apiKey,
3065
+ fetch: config?.fetch,
3066
+ referenceResolver: resolveReference,
3067
+ signal: AbortSignal.timeout(10_000),
3068
+ });
3069
+ if (richModels && richModels.length > 0) {
3070
+ return richModels;
3071
+ }
2835
3072
  return fetchOpenAICompatibleModels({
2836
3073
  api: "openai-completions",
2837
3074
  provider: "litellm",
2838
3075
  baseUrl,
2839
3076
  apiKey,
2840
- mapModel: (entry, defaults) =>
2841
- mapWithBundledReference(entry, defaults, modelsDevReferences.get(defaults.id)),
3077
+ mapModel: (entry, defaults) => mapWithBundledReference(entry, defaults, resolveReference(defaults.id)),
2842
3078
  fetch: config?.fetch,
2843
3079
  });
2844
3080
  },
@@ -1,7 +1,9 @@
1
1
  import { once } from "@oh-my-pi/pi-utils";
2
2
  import { fetchCodexModels } from "../discovery/codex";
3
3
  import type { DevinModelDiscoveryOptions } from "../discovery/devin";
4
+ import { buildGitLabDuoWorkflowFallbackModel, fetchGitLabDuoWorkflowModels } from "../discovery/gitlab-duo-workflow";
4
5
  import type { ModelManagerOptions } from "../model-manager";
6
+ import type { FetchImpl } from "../types";
5
7
 
6
8
  // ---------------------------------------------------------------------------
7
9
  // OpenAI Codex
@@ -58,6 +60,68 @@ export function cursorModelManagerOptions(config: CursorModelManagerConfig = {})
58
60
  const cursorDiscovery = once(() => import("../discovery/cursor"));
59
61
 
60
62
  // ---------------------------------------------------------------------------
63
+ // GitLab Duo Workflow
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export interface GitLabDuoWorkflowModelManagerConfig {
67
+ apiKey?: string;
68
+ baseUrl?: string;
69
+ fetch?: FetchImpl;
70
+ namespaceId?: string;
71
+ projectId?: string;
72
+ cwd?: string;
73
+ }
74
+
75
+ export function gitLabDuoWorkflowModelManagerOptions(
76
+ config: GitLabDuoWorkflowModelManagerConfig = {},
77
+ ): ModelManagerOptions<"gitlab-duo-agent"> {
78
+ const apiKey = config.apiKey;
79
+ return {
80
+ providerId: "gitlab-duo-agent",
81
+ // GitLab Duo discovery is credential- and namespace-specific
82
+ // (`aiChatAvailableModels(rootNamespaceId:)` also surfaces namespace-pinned
83
+ // models), so the default provider-id cache namespace would let a second
84
+ // account/namespace load the first one's authoritative model list at startup
85
+ // and skip refetching. Partition the cache by a non-reversible fingerprint of
86
+ // the exact inputs `fetchGitLabDuoWorkflowModels` resolves the namespace from
87
+ // (credential + base URL + namespace/project config + the same env vars + the
88
+ // effective workspace cwd whose git remote drives auto-discovery). Built-in
89
+ // discovery only passes apiKey/baseUrl/fetch, so the cwd/env terms — not the
90
+ // empty config fields — are what actually separate workspace A from B here.
91
+ // Falls back to the bare provider id when no credential is present.
92
+ ...(apiKey ? { cacheProviderId: gitLabDuoWorkflowModelCacheProviderId(apiKey, config) } : undefined),
93
+ dynamicModelsAuthoritative: true,
94
+ staticModels: [
95
+ buildGitLabDuoWorkflowFallbackModel("claude_sonnet_4_6_vertex", "Claude Sonnet 4.6 - Vertex", config.baseUrl),
96
+ ],
97
+ ...(apiKey
98
+ ? {
99
+ fetchDynamicModels: async () =>
100
+ fetchGitLabDuoWorkflowModels({
101
+ apiKey,
102
+ baseUrl: config.baseUrl,
103
+ fetch: config.fetch,
104
+ namespaceId: config.namespaceId,
105
+ projectId: config.projectId,
106
+ cwd: config.cwd,
107
+ }),
108
+ }
109
+ : undefined),
110
+ };
111
+ }
112
+
113
+ function gitLabDuoWorkflowModelCacheProviderId(apiKey: string, config: GitLabDuoWorkflowModelManagerConfig): string {
114
+ // Mirror the exact inputs `discoverGitLabDuoWorkflowNamespace` keys off: explicit
115
+ // namespace/project config OR the same env vars, then the git remote at the
116
+ // effective cwd. Built-in discovery leaves the config fields empty, so the env +
117
+ // resolved cwd terms are what actually distinguish two workspaces sharing a token.
118
+ const namespaceId = config.namespaceId ?? Bun.env.GITLAB_DUO_NAMESPACE_ID ?? "";
119
+ const projectId = config.projectId ?? Bun.env.GITLAB_DUO_PROJECT_ID ?? Bun.env.GITLAB_DUO_PROJECT_PATH ?? "";
120
+ const cwd = config.cwd ?? process.cwd();
121
+ const scope = [config.baseUrl ?? "", namespaceId, projectId, cwd].join("\u0000");
122
+ return `gitlab-duo-agent:${Bun.hash(`${apiKey}\u0000${scope}`).toString(36)}`;
123
+ }
124
+
61
125
  // Devin (Codeium Cascade)
62
126
  // ---------------------------------------------------------------------------
63
127
 
@@ -84,7 +148,6 @@ export function devinModelManagerOptions(config: DevinModelManagerConfig = {}):
84
148
  }
85
149
 
86
150
  const devinDiscovery = once(() => import("../discovery/devin"));
87
-
88
151
  // ---------------------------------------------------------------------------
89
152
  // Zai
90
153
  // ---------------------------------------------------------------------------
package/src/types.ts CHANGED
@@ -15,6 +15,7 @@ export type KnownApi =
15
15
  | "google-vertex"
16
16
  | "ollama-chat"
17
17
  | "cursor-agent"
18
+ | "gitlab-duo-agent"
18
19
  | "devin-agent";
19
20
  export type Api = KnownApi | (string & {});
20
21
 
@@ -268,6 +269,14 @@ export interface OpenAICompat {
268
269
  * to provider-default auto selection. Default: true.
269
270
  */
270
271
  supportsForcedToolChoice?: boolean;
272
+ /**
273
+ * Whether the chat-completions endpoint accepts the object form that pins one
274
+ * named function (`{ type: "function", function: { name } }`). Some
275
+ * OpenAI-compatible hosts such as llama.cpp only accept string
276
+ * `tool_choice` values; request builders downgrade a named force to
277
+ * `"required"` when this is false. Default: true.
278
+ */
279
+ supportsNamedToolChoice?: boolean;
271
280
  /**
272
281
  * Drop reasoning fields (`reasoning_effort`, OpenRouter `reasoning`) for
273
282
  * the request when `tool_choice` forces a tool call. Mirrors the Anthropic
@@ -465,6 +474,7 @@ export interface ResolvedOpenAISharedCompat {
465
474
  disableReasoningOnToolChoice: boolean;
466
475
  supportsToolChoice: boolean;
467
476
  supportsForcedToolChoice: boolean;
477
+ supportsNamedToolChoice: boolean;
468
478
  reasoningContentField?: OpenAICompat["reasoningContentField"];
469
479
  requiresReasoningContentForToolCalls: boolean;
470
480
  requiresReasoningContentForAllAssistantTurns: boolean;
@@ -516,6 +526,7 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat &
516
526
  | "disableReasoningOnToolChoice"
517
527
  | "supportsToolChoice"
518
528
  | "supportsForcedToolChoice"
529
+ | "supportsNamedToolChoice"
519
530
  | "reasoningContentField"
520
531
  | "requiresReasoningContentForToolCalls"
521
532
  | "requiresReasoningContentForAllAssistantTurns"
@@ -642,6 +653,18 @@ export type CompatOf<TApi extends Api> = TApi extends "openrouter"
642
653
  ? ResolvedDevinCompat
643
654
  : undefined;
644
655
 
656
+ /** Provider-native compaction endpoint configuration for one model. */
657
+ export interface RemoteCompactionConfig<TApi extends Api = Api> {
658
+ /** Enables provider-native compaction for providers not enabled by built-in policy. */
659
+ enabled?: boolean;
660
+ /** Adapter family used by the configured compaction endpoint. */
661
+ api?: TApi;
662
+ /** Absolute compact endpoint URL; when omitted, the adapter derives it from the model base URL. */
663
+ endpoint?: string;
664
+ /** Model id sent to the compaction endpoint when it differs from the active model id. */
665
+ model?: string;
666
+ }
667
+
645
668
  // Model interface for the unified model system
646
669
  export interface Model<TApi extends Api = Api> {
647
670
  id: string;
@@ -672,6 +695,8 @@ export interface Model<TApi extends Api = Api> {
672
695
  * reports that native tool calling is unsupported.
673
696
  */
674
697
  supportsTools?: boolean;
698
+ /** GitLab Duo Workflow root namespace selected during catalog discovery. */
699
+ gitlabDuoWorkflowRootNamespaceId?: string;
675
700
  cost: {
676
701
  input: number; // $/million tokens
677
702
  output: number; // $/million tokens
@@ -714,6 +739,10 @@ export interface Model<TApi extends Api = Api> {
714
739
  preferWebsockets?: boolean;
715
740
  /** Preferred model to switch to when context promotion is triggered (model id or provider/id). */
716
741
  contextPromotionTarget?: string;
742
+ /** Preferred model to use only for compaction (model id or provider/id); the active session model is unchanged. */
743
+ compactionModel?: string;
744
+ /** Provider-native compaction endpoint configuration. */
745
+ remoteCompaction?: RemoteCompactionConfig<TApi>;
717
746
  /** Provider-assigned priority value (lower = higher priority). */
718
747
  priority?: number;
719
748
  /** Canonical thinking capability metadata for this model. */