Skip to content

Commit b3a7a80

Browse files
committed
feat: add pi_model field to expose pi-ai compat options in /v1/models
- Add pi_model config field (provider + model_id) to ModelConfigSchema - Expose pi_options in GET /v1/models when the referenced pi model has compat settings - Add GET /v0/management/pi/providers and /v0/management/pi/models endpoints for frontend autocomplete - Add pi_model column to both SQLite and Postgres schemas with migrations - Wire pi_model read/write through config-repository saveAlias and rowToModelConfig - Add pi model provider/model dropdown UI in ModelMetadataEditor with loading state and confirmation badge - Add getPiProviders/getPiModels API methods and pi_model to Alias type in frontend
1 parent 8df1bf0 commit b3a7a80

8 files changed

Lines changed: 246 additions & 1 deletion

File tree

packages/backend/drizzle/schema/postgres/model-aliases.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const modelAliases = pgTable('model_aliases', {
3434
enforceLimits: boolean('enforce_limits').notNull().default(false),
3535
stickySession: boolean('sticky_session').notNull().default(false),
3636
preferredApi: jsonb('preferred_api'), // ('chat_completions' | 'messages' | 'gemini' | 'responses')[]
37+
piModel: jsonb('pi_model'), // { provider: string, model_id: string }
3738
targetGroups: jsonb('target_groups'), // {name, selector}[]
3839
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
3940
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),

packages/backend/drizzle/schema/sqlite/model-aliases.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const modelAliases = sqliteTable('model_aliases', {
1616
enforceLimits: integer('enforce_limits').notNull().default(0),
1717
stickySession: integer('sticky_session').notNull().default(0),
1818
preferredApi: text('preferred_api'), // JSON: ('chat_completions' | 'messages' | 'gemini' | 'responses')[]
19+
piModel: text('pi_model'), // JSON: { provider: string, model_id: string }
1920
targetGroups: text('target_groups'), // JSON: {name, selector}[]
2021
createdAt: integer('created_at').notNull(),
2122
updatedAt: integer('updated_at').notNull(),

packages/backend/src/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,13 @@ export const ModelConfigSchema = z
594594
.optional(),
595595
advanced: z.array(ModelBehaviorSchema).optional(),
596596
metadata: ModelMetadataSchema.optional(),
597+
// pi-ai model reference: when set, pi_options (compat) will be included in GET /v1/models
598+
pi_model: z
599+
.object({
600+
provider: z.string().min(1),
601+
model_id: z.string().min(1),
602+
})
603+
.optional(),
597604
// Model architecture override for inference energy calculation
598605
model_architecture: z
599606
.object({

packages/backend/src/db/config-repository.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,7 @@ export class ConfigRepository {
678678
enforceLimits: fromBool(config.enforce_limits === true),
679679
stickySession: fromBool(config.sticky_session === true),
680680
preferredApi: config.preferred_api ? toJson(config.preferred_api) : null,
681+
piModel: config.pi_model ? toJson(config.pi_model) : null,
681682
targetGroups:
682683
config.target_groups && config.target_groups.length > 0
683684
? toJson(config.target_groups.map((g) => ({ name: g.name, selector: g.selector })))
@@ -818,6 +819,7 @@ export class ConfigRepository {
818819
// Model architecture override for inference energy calculation
819820
...(row.modelArchitecture ? { model_architecture: parseJson(row.modelArchitecture) } : {}),
820821
...(row.preferredApi ? { preferred_api: parseJson(row.preferredApi) } : {}),
822+
...(row.piModel ? { pi_model: parseJson(row.piModel) } : {}),
821823
};
822824

823825
if (row.metadataSource) {

packages/backend/src/routes/inference/models.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify';
22
import { getConfig } from '../../config';
33
import { PricingManager } from '../../services/pricing-manager';
44
import { ModelMetadataManager, mergeOverrides } from '../../services/model-metadata-manager';
5+
import { getModel } from '@earendil-works/pi-ai';
56

67
export async function registerModelsRoute(fastify: FastifyInstance) {
78
/**
@@ -24,6 +25,20 @@ export async function registerModelsRoute(fastify: FastifyInstance) {
2425

2526
const models = Object.entries(config.models).map(([aliasId, modelConfig]) => {
2627
const metaConfig = modelConfig?.metadata;
28+
const piModelConfig = modelConfig?.pi_model;
29+
30+
// Look up pi compat options if a pi model reference is configured.
31+
let piOptions: Record<string, unknown> | undefined;
32+
if (piModelConfig) {
33+
try {
34+
const piModel = getModel(piModelConfig.provider as any, piModelConfig.model_id as any);
35+
if (piModel?.compat && Object.keys(piModel.compat).length > 0) {
36+
piOptions = piModel.compat as Record<string, unknown>;
37+
}
38+
} catch {
39+
// Unknown provider or model — skip silently.
40+
}
41+
}
2742

2843
const base = {
2944
id: aliasId,
@@ -33,6 +48,7 @@ export async function registerModelsRoute(fastify: FastifyInstance) {
3348
...(modelConfig?.preferred_api !== undefined && {
3449
preferred_api: modelConfig.preferred_api,
3550
}),
51+
...(piOptions !== undefined && { pi_options: piOptions }),
3652
};
3753

3854
if (!metaConfig) {

packages/backend/src/routes/management/models.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
22
import { logger } from '../../utils/logger';
33
import { HuggingFaceModelFetcher } from '../../services/huggingface-model-fetcher';
4+
import { getModels, getProviders } from '@earendil-works/pi-ai';
45

56
interface FetchModelRequest {
67
Params: {
@@ -67,4 +68,43 @@ export async function registerModelRoutes(fastify: FastifyInstance) {
6768
}
6869
}
6970
);
71+
72+
/**
73+
* GET /v0/management/pi/providers
74+
* Returns the list of provider IDs known to the pi-ai library.
75+
*/
76+
fastify.get('/v0/management/pi/providers', async (_request, reply) => {
77+
return reply.send({ data: getProviders() });
78+
});
79+
80+
/**
81+
* GET /v0/management/pi/models
82+
* Returns models for a given pi provider, optionally filtered by a search query.
83+
*
84+
* Query parameters:
85+
* - provider (required): pi provider id (e.g. "openai", "anthropic")
86+
* - q (optional): substring filter on id or name
87+
*/
88+
fastify.get('/v0/management/pi/models', async (request, reply) => {
89+
const query = request.query as { provider?: string; q?: string };
90+
if (!query.provider) {
91+
return reply.status(400).send({ error: `Missing 'provider' parameter` });
92+
}
93+
let models: ReturnType<typeof getModels>;
94+
try {
95+
models = getModels(query.provider as any);
96+
} catch {
97+
return reply.status(400).send({ error: `Unknown pi provider '${query.provider}'` });
98+
}
99+
if (!models || models.length === 0) {
100+
return reply.status(400).send({ error: `Unknown pi provider '${query.provider}'` });
101+
}
102+
const q = (query.q ?? '').toLowerCase();
103+
const filtered = q
104+
? models.filter((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
105+
: models;
106+
return reply.send({
107+
data: filtered.map((m) => ({ id: m.id, name: m.name, api: m.api })),
108+
});
109+
});
70110
}

packages/frontend/src/components/models/ModelMetadataEditor.tsx

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { useState } from 'react';
1+
import { useState, useEffect } from 'react';
22
import { createPortal } from 'react-dom';
33
import { BookOpen, ChevronDown, ChevronRight, CheckCircle, X, Loader2 } from 'lucide-react';
44
import { Input } from '../ui/Input';
55
import { Button } from '../ui/Button';
66
import { Switch } from '../ui/Switch';
77
import { MetadataOverrideForm } from './MetadataOverrideForm';
88
import { useMetadataEditor } from '../../hooks/useMetadataEditor';
9+
import { api } from '../../lib/api';
910
import type {
1011
Alias,
1112
AliasMetadata,
@@ -45,6 +46,33 @@ export function ModelMetadataEditor({ editingAlias, setEditingAlias, isModalOpen
4546
buildCustomDefaults,
4647
} = useMetadataEditor(editingAlias, setEditingAlias, isModalOpen);
4748

49+
// ── Pi model selector state ──────────────────────────────────────────
50+
const [piProviders, setPiProviders] = useState<string[]>([]);
51+
const [piModels, setPiModels] = useState<Array<{ id: string; name: string; api: string }>>([]);
52+
const [piModelsLoading, setPiModelsLoading] = useState(false);
53+
54+
useEffect(() => {
55+
if (!isOpen) return;
56+
api
57+
.getPiProviders()
58+
.then(setPiProviders)
59+
.catch(() => {});
60+
}, [isOpen]);
61+
62+
useEffect(() => {
63+
const provider = editingAlias.pi_model?.provider;
64+
if (!provider) {
65+
setPiModels([]);
66+
return;
67+
}
68+
setPiModelsLoading(true);
69+
api
70+
.getPiModels(provider)
71+
.then(setPiModels)
72+
.catch(() => setPiModels([]))
73+
.finally(() => setPiModelsLoading(false));
74+
}, [editingAlias.pi_model?.provider]);
75+
4876
return (
4977
<>
5078
<div className="border border-border-glass rounded-sm overflow-hidden">
@@ -272,6 +300,134 @@ export function ModelMetadataEditor({ editingAlias, setEditingAlias, isModalOpen
272300
</div>
273301
)}
274302

303+
{/* Pi model */}
304+
<div>
305+
<label
306+
className="font-body text-[12px] font-medium text-text-secondary"
307+
style={{ display: 'block', marginBottom: '4px' }}
308+
>
309+
Pi model
310+
</label>
311+
<p className="font-body text-[11px] text-text-muted" style={{ marginBottom: '6px' }}>
312+
Link to a pi-ai model to include its compatibility options as{' '}
313+
<code className="text-primary">pi_options</code> in{' '}
314+
<code className="text-primary">GET /v1/models</code>.
315+
</p>
316+
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
317+
{/* Provider dropdown */}
318+
<select
319+
className="font-body text-xs text-text bg-bg-glass border border-border-glass rounded-sm outline-none transition-all duration-200 backdrop-blur-md focus:border-primary"
320+
style={{
321+
padding: '5px 8px',
322+
height: '30px',
323+
flex: '0 0 auto',
324+
maxWidth: '160px',
325+
}}
326+
value={editingAlias.pi_model?.provider ?? ''}
327+
onChange={(e) => {
328+
const provider = e.target.value;
329+
if (!provider) {
330+
const { pi_model: _removed, ...rest } = editingAlias;
331+
setEditingAlias(rest as Alias);
332+
} else {
333+
setEditingAlias({ ...editingAlias, pi_model: { provider, model_id: '' } });
334+
}
335+
}}
336+
>
337+
<option value="">None</option>
338+
{piProviders.map((p) => (
339+
<option key={p} value={p}>
340+
{p}
341+
</option>
342+
))}
343+
</select>
344+
345+
{/* Model dropdown */}
346+
{editingAlias.pi_model?.provider && (
347+
<div style={{ position: 'relative', flex: 1 }}>
348+
<select
349+
className="w-full font-body text-xs text-text bg-bg-glass border border-border-glass rounded-sm outline-none transition-all duration-200 backdrop-blur-md focus:border-primary"
350+
style={{
351+
padding: '5px 8px',
352+
height: '30px',
353+
paddingRight: piModelsLoading ? '28px' : undefined,
354+
}}
355+
value={editingAlias.pi_model?.model_id ?? ''}
356+
onChange={(e) => {
357+
const model_id = e.target.value;
358+
setEditingAlias({
359+
...editingAlias,
360+
pi_model: { provider: editingAlias.pi_model!.provider, model_id },
361+
});
362+
}}
363+
>
364+
<option value="">Select model...</option>
365+
{piModels.map((m) => (
366+
<option key={m.id} value={m.id}>
367+
{m.name} ({m.id})
368+
</option>
369+
))}
370+
</select>
371+
{piModelsLoading && (
372+
<Loader2
373+
size={14}
374+
className="animate-spin text-text-muted"
375+
style={{
376+
position: 'absolute',
377+
right: '8px',
378+
top: '50%',
379+
transform: 'translateY(-50%)',
380+
pointerEvents: 'none',
381+
}}
382+
/>
383+
)}
384+
</div>
385+
)}
386+
387+
{/* Clear pi model */}
388+
{editingAlias.pi_model?.model_id && (
389+
<Button
390+
variant="ghost"
391+
size="sm"
392+
onClick={() => {
393+
const { pi_model: _removed, ...rest } = editingAlias;
394+
setEditingAlias(rest as Alias);
395+
}}
396+
style={{
397+
color: 'var(--color-danger)',
398+
padding: '4px',
399+
minHeight: 'auto',
400+
flex: '0 0 auto',
401+
}}
402+
title="Remove pi model"
403+
>
404+
<X size={14} />
405+
</Button>
406+
)}
407+
</div>
408+
409+
{/* Confirmation badge */}
410+
{editingAlias.pi_model?.model_id && (
411+
<div
412+
className="rounded-sm border border-border-glass bg-bg-subtle px-3 py-2"
413+
style={{
414+
fontSize: '11px',
415+
color: 'var(--color-text-secondary)',
416+
marginTop: '6px',
417+
}}
418+
>
419+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
420+
<CheckCircle size={12} className="text-success" />
421+
<span>
422+
Pi model: <strong>{editingAlias.pi_model.provider}</strong>
423+
{' / '}
424+
<code className="text-primary">{editingAlias.pi_model.model_id}</code>
425+
</span>
426+
</div>
427+
</div>
428+
)}
429+
</div>
430+
275431
{/* Preferred API */}
276432
<div>
277433
<label

packages/frontend/src/lib/api.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ export interface Alias {
393393
enforce_limits?: boolean;
394394
sticky_session?: boolean;
395395
preferred_api?: Array<PreferredApiValue>;
396+
pi_model?: { provider: string; model_id: string };
396397
}
397398

398399
export interface InferenceError {
@@ -1820,6 +1821,7 @@ export const api = {
18201821
...(alias.type && { type: alias.type }),
18211822
...(alias.advanced && alias.advanced.length > 0 && { advanced: alias.advanced }),
18221823
...(alias.metadata && { metadata: alias.metadata }),
1824+
...(alias.pi_model && { pi_model: alias.pi_model }),
18231825
// Model architecture override for inference energy calculation
18241826
...(alias.model_architecture && { model_architecture: alias.model_architecture }),
18251827
target_groups: alias.target_groups.map((g) => ({
@@ -1942,6 +1944,7 @@ export const api = {
19421944
metadata: val.metadata,
19431945
model_architecture: val.model_architecture,
19441946
preferred_api: val.preferred_api || [],
1947+
pi_model: val.pi_model,
19451948
});
19461949
});
19471950
return aliases;
@@ -2528,6 +2531,25 @@ export const api = {
25282531
return json.data;
25292532
},
25302533

2534+
getPiProviders: async (): Promise<string[]> => {
2535+
const res = await fetchWithAuth(`${API_BASE}/v0/management/pi/providers`);
2536+
if (!res.ok) throw new Error('Failed to fetch pi providers');
2537+
const json = (await res.json()) as { data: string[] };
2538+
return json.data;
2539+
},
2540+
2541+
getPiModels: async (
2542+
provider: string,
2543+
q?: string
2544+
): Promise<Array<{ id: string; name: string; api: string }>> => {
2545+
const params = new URLSearchParams({ provider });
2546+
if (q) params.set('q', q);
2547+
const res = await fetchWithAuth(`${API_BASE}/v0/management/pi/models?${params}`);
2548+
if (!res.ok) throw new Error('Failed to fetch pi models');
2549+
const json = (await res.json()) as { data: Array<{ id: string; name: string; api: string }> };
2550+
return json.data;
2551+
},
2552+
25312553
getOAuthProviderModels: async (
25322554
providerId: string
25332555
): Promise<

0 commit comments

Comments
 (0)