|
1 | 1 | import type { Server } from 'node:http'; |
2 | 2 | import { Agent, createServer } from 'node:http'; |
3 | 3 | import type { AddressInfo } from 'node:net'; |
| 4 | +import type { EndpointService, ServiceModelList } from '@linkcode/providers'; |
4 | 5 | import type { WirePayload } from '@linkcode/schema'; |
5 | 6 | import { nullthrow } from 'foxts/guard'; |
6 | 7 | import { afterEach, describe, expect, it, vi } from 'vitest'; |
7 | | -import { probeEndpointModels, requestPublicModelList } from '../agent/model-probe'; |
| 8 | +import { probeServiceModels, requestPublicModelList } from '../agent/model-probe'; |
8 | 9 | import { InMemoryProviderConfigStore } from '../agent/provider-config'; |
9 | 10 | import { createSessionHarness } from './fixtures/session-harness'; |
10 | 11 |
|
@@ -37,13 +38,29 @@ function baseUrl(server: Server): string { |
37 | 38 |
|
38 | 39 | let relay: Server | undefined; |
39 | 40 |
|
40 | | -/** Sends the catalog's own path at the local relay, so the relay records which path the service |
41 | | - * descriptor resolved to while the real HTTP round-trip stays under test. */ |
42 | | -const localModelProbe: typeof probeEndpointModels = (source, secret) => { |
43 | | - const resolved = new URL(source.url); |
| 41 | +/** Rewrites every model-list URL a service carries — service-level and per-variant — to the |
| 42 | + * local relay, so the relay records which path each one resolved to while the real HTTP |
| 43 | + * round-trip stays under test. */ |
| 44 | +function localize(list: ServiceModelList | undefined): ServiceModelList | undefined { |
| 45 | + if (!list) return undefined; |
| 46 | + const resolved = new URL(list.url); |
44 | 47 | const local = `${baseUrl(nullthrow(relay, 'relay not started'))}${resolved.pathname}${resolved.search}`; |
| 48 | + return { ...list, url: local }; |
| 49 | +} |
| 50 | + |
| 51 | +const localModelProbe: typeof probeServiceModels = (service, secret) => { |
| 52 | + const localized: EndpointService = { |
| 53 | + ...service, |
| 54 | + models: localize(service.models), |
| 55 | + variants: Object.fromEntries( |
| 56 | + Object.entries(service.variants).map(([protocol, variant]) => [ |
| 57 | + protocol, |
| 58 | + variant && { ...variant, models: localize(variant.models) }, |
| 59 | + ]), |
| 60 | + ), |
| 61 | + }; |
45 | 62 | // An unguarded agent: the relay is on loopback, which the probe policy exists to refuse. |
46 | | - return probeEndpointModels({ ...source, url: local }, secret, (url, headers) => |
| 63 | + return probeServiceModels(localized, secret, (url, headers) => |
47 | 64 | requestPublicModelList(url, headers, undefined, new Agent()), |
48 | 65 | ); |
49 | 66 | }; |
@@ -89,10 +106,15 @@ describe('config.probe-models', () => { |
89 | 106 | credential: { type: 'inline', secret: { type: 'api-key', key: 'sk-test' } }, |
90 | 107 | }); |
91 | 108 |
|
| 109 | + // deepseek's three variants share one list (no per-variant override), so both ids are |
| 110 | + // tagged with all three protocols from the single request `seen` below confirms. |
92 | 111 | await expect(replyFor(h.sent, 'probe-1')).resolves.toEqual({ |
93 | 112 | kind: 'config.probe-models.result', |
94 | 113 | replyTo: 'probe-1', |
95 | | - models: [{ id: 'gpt-5' }, { id: 'gpt-5-mini' }], |
| 114 | + models: [ |
| 115 | + { id: 'gpt-5', protocols: ['anthropic', 'openai-chat', 'openai-responses'] }, |
| 116 | + { id: 'gpt-5-mini', protocols: ['anthropic', 'openai-chat', 'openai-responses'] }, |
| 117 | + ], |
96 | 118 | }); |
97 | 119 | // The service's own path, not one derived from a variant's baseUrl. |
98 | 120 | expect(seen).toEqual(['/models']); |
@@ -166,11 +188,56 @@ describe('config.probe-models', () => { |
166 | 188 | await expect(replyFor(h.sent, 'probe-4')).resolves.toEqual({ |
167 | 189 | kind: 'config.probe-models.result', |
168 | 190 | replyTo: 'probe-4', |
169 | | - models: [{ id: 'deepseek-v4-pro' }], |
| 191 | + models: [ |
| 192 | + { id: 'deepseek-v4-pro', protocols: ['anthropic', 'openai-chat', 'openai-responses'] }, |
| 193 | + ], |
170 | 194 | }); |
171 | 195 | expect(seen).toEqual(['/models']); |
172 | 196 | }); |
173 | 197 |
|
| 198 | + it('probes every distinct list a service carries and merges the results', async () => { |
| 199 | + const seen: string[] = []; |
| 200 | + relay = await startRelay((url) => { |
| 201 | + seen.push(url); |
| 202 | + if (url === '/v1/models?protocol=openai-responses') { |
| 203 | + return { status: 200, body: JSON.stringify({ data: [{ id: 'openai/gpt-5.6' }] }) }; |
| 204 | + } |
| 205 | + if (url === '/v1/models') { |
| 206 | + return { |
| 207 | + status: 200, |
| 208 | + body: JSON.stringify({ |
| 209 | + data: [{ id: 'openai/gpt-5.6' }, { id: 'anthropic/claude-sonnet-5' }], |
| 210 | + }), |
| 211 | + }; |
| 212 | + } |
| 213 | + return { status: 404, body: '{}' }; |
| 214 | + }); |
| 215 | + const h = createHarness(); |
| 216 | + await h.engine.start(); |
| 217 | + |
| 218 | + await h.inject({ |
| 219 | + kind: 'config.probe-models', |
| 220 | + clientReqId: 'probe-7', |
| 221 | + service: 'linkcode-gateway', |
| 222 | + credential: { type: 'inline', secret: { type: 'auth-token', token: 'lc-test' } }, |
| 223 | + }); |
| 224 | + |
| 225 | + const reply = await replyFor(h.sent, 'probe-7'); |
| 226 | + if (reply.kind !== 'config.probe-models.result') throw new Error('no result for probe-7'); |
| 227 | + expect(reply.models).toEqual( |
| 228 | + expect.arrayContaining([ |
| 229 | + { |
| 230 | + id: 'openai/gpt-5.6', |
| 231 | + // Returned by both lists: tagged with every protocol whose list named it. |
| 232 | + protocols: ['openai-chat', 'openai-responses'], |
| 233 | + }, |
| 234 | + { id: 'anthropic/claude-sonnet-5', protocols: ['openai-chat'] }, |
| 235 | + ]), |
| 236 | + ); |
| 237 | + expect(reply.models).toHaveLength(2); |
| 238 | + expect(seen.sort()).toEqual(['/v1/models', '/v1/models?protocol=openai-responses']); |
| 239 | + }); |
| 240 | + |
174 | 241 | it('refuses to send an account secret to a service it does not belong to', async () => { |
175 | 242 | const reached: string[] = []; |
176 | 243 | relay = await startRelay((url) => { |
|
0 commit comments