Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ By default, the `jlpm build` command generates the source maps for this extensio
jupyter lab build --minimize=False
```

## Refreshing built-in model context windows

When updating the built-in model lists, refresh the generated context window
metadata from [`models.dev`](https://github.com/anomalyco/models.dev) with:

```bash
jlpm sync:model-context-windows
```

This script fetches the latest metadata, rewrites
`src/providers/generated-context-windows.ts`, then runs `prettier` and `eslint`
on the generated file. The command will warn if `models.dev` does not expose a
matching context window for one of our built-in model IDs.

## Running UI tests

The UI tests use Playwright and can be configured with environment variables:
Expand Down
6 changes: 6 additions & 0 deletions docs/custom-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ const plugin: JupyterFrontEndPlugin<void> = {
name: 'My Custom Provider',
apiKeyRequirement: 'required' as const,
defaultModels: ['my-model'],
modelInfo: {
'my-model': {
contextWindow: 128000
}
},
supportsBaseURL: true,
factory: (options: {
apiKey: string;
Expand All @@ -51,6 +56,7 @@ The provider configuration object requires the following properties:
- `name`: Display name shown in the settings UI
- `apiKeyRequirement`: Whether an API key is `'required'`, `'optional'`, or `'none'`
- `defaultModels`: Array of model names to show in the settings
- `modelInfo` (optional): Per-model metadata such as `contextWindow`
- `supportsBaseURL`: Whether the provider supports a custom base URL
- `factory`: Function that creates and returns a language model (the registry automatically wraps it for chat usage)

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
"watch:src": "tsc -w --sourceMap",
"watch:labextension": "jupyter labextension watch .",
"docs": "jupyter book start",
"docs:build": "sed -e 's/\\[@/[/g' -e 's/@/\\&#64;/g' CHANGELOG.md > docs/_changelog_content.md && jupyter book build --html"
"docs:build": "sed -e 's/\\[@/[/g' -e 's/@/\\&#64;/g' CHANGELOG.md > docs/_changelog_content.md && jupyter book build --html",
"sync:model-context-windows": "node scripts/sync-model-context-windows.mjs && prettier --write src/providers/generated-context-windows.ts && eslint --fix src/providers/generated-context-windows.ts"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a follow up PR we could add a workflow to update it every X days and open a PR.

},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",
Expand Down
11 changes: 11 additions & 0 deletions schema/settings-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
"maximum": 100,
"default": 25
},
"contextWindow": {
"type": "number",
"description": "Model context window size in tokens (used for context usage estimation)",
"minimum": 1
},
"supportsFillInMiddle": {
"type": "boolean",
"description": "Whether the model supports fill-in-middle completion"
Expand Down Expand Up @@ -211,6 +216,12 @@
"type": "boolean",
"default": false
},
"showContextUsage": {
"title": "Show Context Usage",
"description": "Display estimated context usage percentage in the chat toolbar",
"type": "boolean",
"default": false
},
"commandsRequiringApproval": {
"title": "Commands Requiring Approval",
"description": "List of commands that require user approval before AI can execute them",
Expand Down
228 changes: 228 additions & 0 deletions scripts/sync-model-context-windows.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#!/usr/bin/env node

import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, '..');
const BUILT_IN_PROVIDERS_FILE = path.join(
ROOT_DIR,
'src/providers/built-in-providers.ts'
);
const OUTPUT_FILE = path.join(
ROOT_DIR,
'src/providers/generated-context-windows.ts'
);
const BUILT_IN_PROVIDER_IDS = ['anthropic', 'google', 'mistral', 'openai'];
const DATE_SUFFIX = /^(.*)-\d{4}-\d{2}-\d{2}$/;
const SHORT_VERSION_SUFFIX = /^(.*)-\d{4}$/;

function extractDefaultModels(source, providerId) {
const pattern = new RegExp(
`export const ${providerId}Provider: IProviderInfo = \\{[\\s\\S]*?defaultModels: \\[([\\s\\S]*?)\\]\\s*,`,
'm'
);
const match = source.match(pattern);

if (!match) {
throw new Error(
`Could not find defaultModels for provider "${providerId}" in ${BUILT_IN_PROVIDERS_FILE}`
);
}

return [...match[1].matchAll(/'([^']+)'/g)].map(entry => entry[1]);
}

function normalizeProviders(payload) {
if (Array.isArray(payload)) {
return Object.fromEntries(
payload
.filter(provider => provider && typeof provider.id === 'string')
.map(provider => [provider.id, provider])
);
}

if (!payload || typeof payload !== 'object') {
throw new Error('Unexpected models.dev payload shape');
}

if (payload.providers && typeof payload.providers === 'object') {
return payload.providers;
}

if (
payload['.opencode.models'] &&
typeof payload['.opencode.models'] === 'object'
) {
return payload['.opencode.models'];
}

return payload;
}

function getCandidateModelIds(modelId, providerModels = {}) {
const candidates = [modelId];

if (modelId.endsWith('-latest')) {
const familyId = modelId.slice(0, -7);
candidates.push(familyId);
candidates.push(
...Object.keys(providerModels)
.filter(candidateId => {
if (providerModels[candidateId]?.limit?.context === undefined) {
return false;
}
return (
candidateId === familyId || candidateId.startsWith(`${familyId}-`)
);
})
.sort((a, b) => b.localeCompare(a))
);
}

const dateSuffixMatch = modelId.match(DATE_SUFFIX);
if (dateSuffixMatch) {
candidates.push(dateSuffixMatch[1]);
}

const shortVersionSuffixMatch = modelId.match(SHORT_VERSION_SUFFIX);
if (shortVersionSuffixMatch) {
candidates.push(shortVersionSuffixMatch[1]);
}

return [...new Set(candidates)];
}

async function loadModelsDevPayload() {
const response = await fetch('https://models.dev/api.json');

if (!response.ok) {
throw new Error(
`Failed to fetch https://models.dev/api.json: ${response.status} ${response.statusText}`
);
}

return response.json();
}

function renderGeneratedFile(contextWindows) {
const generatedAt = new Date().toISOString();
const providersSource = BUILT_IN_PROVIDER_IDS.map(providerId => {
const models = contextWindows[providerId];
const renderedModels = Object.entries(models)
.map(
([modelId, modelInfo]) =>
` '${modelId}': { contextWindow: ${modelInfo.contextWindow} }`
)
.join(',\n');

return ` ${providerId}: {\n${renderedModels}\n }`;
}).join(',\n');

return `/**
* This file is generated by \`jlpm sync:model-context-windows\`.
* Source: https://models.dev/api.json
* Backed by: https://github.com/anomalyco/models.dev
* Generated: ${generatedAt}
*/

import type { IProviderModelInfo } from '../tokens';

export const BUILT_IN_PROVIDER_MODEL_INFO: Record<
string,
Record<string, IProviderModelInfo>
> = {
${providersSource}
};
`;
}

async function main() {
const builtInProvidersSource = await readFile(
BUILT_IN_PROVIDERS_FILE,
'utf8'
);
const modelsDevPayload = await loadModelsDevPayload();
const providers = normalizeProviders(modelsDevPayload);
const contextWindows = {};
const aliasResolutions = [];
const missingModels = [];

for (const providerId of BUILT_IN_PROVIDER_IDS) {
const providerData = providers[providerId];

if (!providerData || typeof providerData !== 'object') {
throw new Error(`Provider "${providerId}" was not found in models.dev`);
}

const providerModels =
providerData.models && typeof providerData.models === 'object'
? providerData.models
: {};
const defaultModels = extractDefaultModels(
builtInProvidersSource,
providerId
);
const resolvedModels = {};
const unresolvedModels = [];

for (const modelId of defaultModels) {
let resolvedId;

for (const candidateId of getCandidateModelIds(modelId, providerModels)) {
if (providerModels[candidateId]?.limit?.context !== undefined) {
resolvedId = candidateId;
break;
}
}

if (!resolvedId) {
unresolvedModels.push(modelId);
continue;
}

const contextWindow = providerModels[resolvedId].limit.context;

resolvedModels[modelId] = { contextWindow };
resolvedModels[resolvedId] = { contextWindow };

if (resolvedId !== modelId) {
aliasResolutions.push({ providerId, modelId, resolvedId });
}
}

contextWindows[providerId] = resolvedModels;

if (unresolvedModels.length > 0) {
missingModels.push({ providerId, modelIds: unresolvedModels });
}
}

await writeFile(OUTPUT_FILE, renderGeneratedFile(contextWindows));

console.log(`Wrote ${path.relative(ROOT_DIR, OUTPUT_FILE)}.`);
console.log(
`Resolved ${aliasResolutions.length} aliased model IDs from built-in defaults.`
);

if (aliasResolutions.length > 0) {
for (const { providerId, modelId, resolvedId } of aliasResolutions) {
console.log(` ${providerId}: ${modelId} -> ${resolvedId}`);
}
}

if (missingModels.length > 0) {
console.warn(
'\nmodels.dev does not currently expose context windows for some built-in model IDs:'
);
for (const { providerId, modelIds } of missingModels) {
console.warn(` ${providerId}: ${modelIds.join(', ')}`);
}
}
}

main().catch(error => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
Loading
Loading