@shipfox/api-agent 13.1.0 → 14.0.0

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
  "name": "@shipfox/api-agent",
3
3
  "license": "MIT",
4
- "version": "13.1.0",
4
+ "version": "14.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -39,7 +39,7 @@
39
39
  "drizzle-orm": "^0.45.2",
40
40
  "zod": "^4.4.3",
41
41
  "@shipfox/node-error-monitoring": "0.3.0",
42
- "@shipfox/api-agent-dto": "13.1.0",
42
+ "@shipfox/api-agent-dto": "14.0.0",
43
43
  "@shipfox/api-auth-context": "12.2.0",
44
44
  "@shipfox/api-secrets-dto": "12.0.0",
45
45
  "@shipfox/inter-module": "0.2.3",
@@ -155,6 +155,23 @@ describe('agent config', () => {
155
155
  }),
156
156
  'model API is invalid',
157
157
  ],
158
+ ...(
159
+ [
160
+ ['null context window', {context_window: null}],
161
+ ['zero max output tokens', {max_output_tokens: 0}],
162
+ ['fractional context window', {context_window: 1.5}],
163
+ ['string context window', {context_window: '128000'}],
164
+ ['non-boolean reasoning', {reasoning: 'true'}],
165
+ ['non-boolean input image', {input_image: 1}],
166
+ ] as const
167
+ ).map(
168
+ ([name, metadata]) =>
169
+ [
170
+ `invalid ${name}`,
171
+ managedProviderWithModelMetadata(metadata),
172
+ 'model metadata is invalid',
173
+ ] as const,
174
+ ),
158
175
  [
159
176
  'unregistered default model',
160
177
  managedProvider({defaultModel: 'missing-model'}),
@@ -277,3 +294,17 @@ function managedProvider(overrides: Partial<ManagedModelProvider> = {}): Managed
277
294
  ...overrides,
278
295
  };
279
296
  }
297
+
298
+ function managedProviderWithModelMetadata(metadata: object): ManagedModelProvider {
299
+ return managedProvider({
300
+ models: [
301
+ {
302
+ id: 'managed-model',
303
+ label: 'Managed model',
304
+ api: 'anthropic-messages',
305
+ ...metadata,
306
+ } as ManagedModelEntry,
307
+ ],
308
+ defaultModel: 'managed-model',
309
+ });
310
+ }
package/src/config.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  isReservedModelProviderId,
5
5
  type ManagedModelProvider,
6
6
  managedModelApiSchema,
7
+ managedModelMetadataSchema,
7
8
  modelProviderRefSchema,
8
9
  SUPPORTED_MODEL_PROVIDER_IDS,
9
10
  type WorkspaceProvidersPolicy,
@@ -143,6 +144,11 @@ function assertManagedProvider(provider: ManagedModelProvider): void {
143
144
  if (!managedModelApiSchema.safeParse(model.api).success) {
144
145
  throw new Error(`Managed model provider model API is invalid: ${provider.id}/${model.id}.`);
145
146
  }
147
+ if (!managedModelMetadataSchema.safeParse(model).success) {
148
+ throw new Error(
149
+ `Managed model provider model metadata is invalid: ${provider.id}/${model.id}.`,
150
+ );
151
+ }
146
152
  }
147
153
 
148
154
  if (!modelIds.has(provider.defaultModel)) {
@@ -78,12 +78,115 @@ describe('resolveRuntimeCredentials', () => {
78
78
  base_url: 'https://gateway.example.test',
79
79
  headers: [],
80
80
  secret_header_names: [],
81
- models: [{id: 'responses-model', label: 'Responses model'}],
81
+ models: [
82
+ {
83
+ id: 'responses-model',
84
+ label: 'Responses model',
85
+ context_window: 1_000_000,
86
+ max_output_tokens: 65_536,
87
+ reasoning: true,
88
+ input_image: true,
89
+ },
90
+ ],
82
91
  requires_api_key: true,
83
92
  },
84
93
  });
85
94
  });
86
95
 
96
+ it('omits optional model metadata when the managed model entry does not carry it', async () => {
97
+ const resolveCredentials = vi.fn<ManagedModelProvider['resolveCredentials']>();
98
+ resolveCredentials.mockResolvedValue({
99
+ api: 'openai-completions',
100
+ baseUrl: 'https://gateway.example.test',
101
+ credentials: {api_key: 'managed-token'},
102
+ });
103
+
104
+ const result = await resolveRuntimeCredentials(
105
+ {
106
+ workspaceId,
107
+ runId: crypto.randomUUID(),
108
+ stepAttemptId: crypto.randomUUID(),
109
+ harness: 'pi',
110
+ provider: 'shipfox',
111
+ model: 'plain-model',
112
+ thinking: 'high',
113
+ },
114
+ {managedProvider: managedProvider(resolveCredentials)},
115
+ );
116
+
117
+ expect(result).toEqual({
118
+ harness: 'pi',
119
+ provider_id: 'shipfox',
120
+ model: 'plain-model',
121
+ thinking: 'high',
122
+ credentials: {api_key: 'managed-token'},
123
+ custom_provider: {
124
+ api: 'openai-completions',
125
+ base_url: 'https://gateway.example.test',
126
+ headers: [],
127
+ secret_header_names: [],
128
+ models: [{id: 'plain-model', label: 'Plain model'}],
129
+ requires_api_key: true,
130
+ },
131
+ });
132
+ });
133
+
134
+ it('forwards partial managed model metadata, including false values', async () => {
135
+ const resolveCredentials = vi.fn<ManagedModelProvider['resolveCredentials']>();
136
+ resolveCredentials.mockResolvedValue({
137
+ api: 'openai-completions',
138
+ baseUrl: 'https://gateway.example.test',
139
+ credentials: {api_key: 'managed-token'},
140
+ });
141
+
142
+ const result = await resolveRuntimeCredentials(
143
+ {
144
+ workspaceId,
145
+ runId: crypto.randomUUID(),
146
+ stepAttemptId: crypto.randomUUID(),
147
+ harness: 'pi',
148
+ provider: 'shipfox',
149
+ model: 'partial-model',
150
+ thinking: 'high',
151
+ },
152
+ {managedProvider: managedProvider(resolveCredentials)},
153
+ );
154
+
155
+ expect(result.custom_provider?.models).toEqual([
156
+ {
157
+ id: 'partial-model',
158
+ label: 'Partial model',
159
+ max_output_tokens: 8_192,
160
+ reasoning: false,
161
+ input_image: false,
162
+ },
163
+ ]);
164
+ });
165
+
166
+ it('falls back to an unregistered managed model descriptor', async () => {
167
+ const resolveCredentials = vi.fn<ManagedModelProvider['resolveCredentials']>();
168
+ resolveCredentials.mockResolvedValue({
169
+ api: 'openai-completions',
170
+ baseUrl: 'https://gateway.example.test',
171
+ credentials: {api_key: 'managed-token'},
172
+ });
173
+
174
+ const result = await resolveRuntimeCredentials(
175
+ {
176
+ workspaceId,
177
+ runId: crypto.randomUUID(),
178
+ stepAttemptId: crypto.randomUUID(),
179
+ harness: 'pi',
180
+ provider: 'shipfox',
181
+ model: 'missing-model',
182
+ thinking: 'high',
183
+ },
184
+ {managedProvider: managedProvider(resolveCredentials)},
185
+ );
186
+
187
+ expect(result.custom_provider?.models).toEqual([{id: 'missing-model', label: 'missing-model'}]);
188
+ });
189
+
87
190
  it('resolves managed provider credentials into the Claude per-step contract', async () => {
88
191
  const resolveCredentials = vi.fn<ManagedModelProvider['resolveCredentials']>();
89
192
  resolveCredentials.mockResolvedValue({
@@ -476,7 +579,24 @@ function managedProvider(
476
579
  label: 'Shipfox',
477
580
  models: [
478
581
  {id: 'claude-model', label: 'Claude model', api: 'anthropic-messages'},
479
- {id: 'responses-model', label: 'Responses model', api: 'openai-responses'},
582
+ {
583
+ id: 'responses-model',
584
+ label: 'Responses model',
585
+ api: 'openai-responses',
586
+ context_window: 1_000_000,
587
+ max_output_tokens: 65_536,
588
+ reasoning: true,
589
+ input_image: true,
590
+ },
591
+ {
592
+ id: 'partial-model',
593
+ label: 'Partial model',
594
+ api: 'openai-completions',
595
+ max_output_tokens: 8_192,
596
+ reasoning: false,
597
+ input_image: false,
598
+ },
599
+ {id: 'plain-model', label: 'Plain model', api: 'openai-completions'},
480
600
  ],
481
601
  defaultModel: 'responses-model',
482
602
  resolveCredentials,
@@ -8,6 +8,7 @@ import type {
8
8
  SupportedModelProviderId,
9
9
  WorkspaceProvidersPolicy,
10
10
  } from '@shipfox/api-agent-dto';
11
+ import {toCustomAgentModelDto} from '@shipfox/api-agent-dto';
11
12
  import {secretsInterModuleContract} from '@shipfox/api-secrets-dto/inter-module';
12
13
  import {isInterModuleKnownError} from '@shipfox/inter-module';
13
14
  import {config, workspaceProvidersPolicy} from '#config.js';
@@ -198,7 +199,7 @@ function toResponse(
198
199
 
199
200
  if (managed !== undefined) {
200
201
  const model = managed.provider.models.find((candidate) => candidate.id === params.model);
201
- const modelDescriptor = {id: params.model, label: model?.label ?? params.model};
202
+ const modelDescriptor = toCustomAgentModelDto(model ?? {id: params.model, label: params.model});
202
203
 
203
204
  if (params.harness === 'pi') {
204
205
  response.custom_provider = {