Skip to content
Closed
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
2 changes: 1 addition & 1 deletion open-sse/translator/helpers/geminiHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [
// Basic constraints (not supported by Gemini API)
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
"pattern", "minItems", "maxItems", "format",
"minItems", "maxItems", "format",
// Claude rejects these in VALIDATED mode
"default", "examples",
// JSON Schema meta keywords
Expand Down
55 changes: 38 additions & 17 deletions src/shared/components/ModelSelectModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default function ModelSelectModal({
const [providerNodes, setProviderNodes] = useState([]);
const [customModels, setCustomModels] = useState([]);
const [disabledModels, setDisabledModels] = useState({});
const [dynamicModels, setDynamicModels] = useState({});

const fetchCombos = async () => {
try {
Expand All @@ -58,10 +59,6 @@ export default function ModelSelectModal({
}
};

useEffect(() => {
if (isOpen) fetchCombos();
}, [isOpen]);

const fetchProviderNodes = async () => {
try {
const res = await fetch("/api/provider-nodes");
Expand All @@ -74,10 +71,6 @@ export default function ModelSelectModal({
}
};

useEffect(() => {
if (isOpen) fetchProviderNodes();
}, [isOpen]);

const fetchCustomModels = async () => {
try {
const res = await fetch("/api/models/custom");
Expand All @@ -90,10 +83,6 @@ export default function ModelSelectModal({
}
};

useEffect(() => {
if (isOpen) fetchCustomModels();
}, [isOpen]);

const fetchDisabledModels = async () => {
try {
const res = await fetch("/api/models/disabled");
Expand All @@ -106,12 +95,40 @@ export default function ModelSelectModal({
}
};

useEffect(() => {
if (isOpen) fetchDisabledModels();
}, [isOpen]);

const allProviders = useMemo(() => ({ ...OAUTH_PROVIDERS, ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...APIKEY_PROVIDERS }), []);

useEffect(() => {
if (isOpen) {
fetchCombos();
fetchProviderNodes();
fetchCustomModels();
fetchDisabledModels();

// Fetch dynamic models for providers that have modelsFetcher defined
const providersWithFetcher = Object.values(allProviders).filter(p => p.modelsFetcher);
providersWithFetcher.forEach(provider => {
const fetchUrl = provider.modelsFetcher.url.startsWith("http")
? `/api/proxy?url=${encodeURIComponent(provider.modelsFetcher.url)}`
: provider.modelsFetcher.url;

fetch(fetchUrl)
.then((res) => (res.ok ? res.json() : { models: [] }))
.then((data) => {
setDynamicModels(prev => ({
...prev,
[provider.id]: data.models || []
}));
})
.catch(() => {
setDynamicModels(prev => ({
...prev,
[provider.id]: []
}));
});
});
}
}, [isOpen, allProviders]);

// Group models by provider with priority order
const groupedModels = useMemo(() => {
const groups = {};
Expand Down Expand Up @@ -262,10 +279,14 @@ export default function ModelSelectModal({
.filter((m) => m.providerAlias === alias && !hardcodedIds.has(m.id) && !customAliasIds.has(m.id))
.map((m) => ({ id: m.id, name: m.name || m.id, value: `${alias}/${m.id}`, isCustom: true }));

const dynamicModelsForProvider = dynamicModels[providerId] || [];
const merged = [
...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, type: m.type })),
...customAliasModels,
...customRegisteredModels,
...dynamicModelsForProvider
.filter((fm) => !hardcodedIds.has(fm.id))
.map((m) => ({ id: m.id, name: m.name || m.id, value: `${alias}/${m.id}`, isCustom: true })),
];
// Dedupe by value (alias may equal hardcoded id, causing React key collision)
const seen = new Set();
Expand Down Expand Up @@ -308,7 +329,7 @@ export default function ModelSelectModal({
});

return groups;
}, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders]);
}, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, dynamicModels, kindFilter, activeProviders]);

// Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design)
const filteredCombos = useMemo(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/shared/constants/providers.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions tests/unit/dynamic-provider-models.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { AI_PROVIDERS } from "../../src/shared/constants/providers";

describe("Dynamic Provider Models Configuration", () => {
it("should have modelsFetcher metadata for kilocode", () => {
const kilocode = AI_PROVIDERS.kilocode;
expect(kilocode).toBeDefined();
expect(kilocode.modelsFetcher).toBeDefined();
expect(kilocode.modelsFetcher.url).toBe("/api/providers/kilo/free-models");
expect(kilocode.passthroughModels).toBe(true);
});

it("should have modelsFetcher for opencode", () => {
const opencode = AI_PROVIDERS.opencode;
expect(opencode).toBeDefined();
expect(opencode.modelsFetcher).toBeDefined();
expect(opencode.passthroughModels).toBe(true);
});

it("should have modelsFetcher for openrouter", () => {
const openrouter = AI_PROVIDERS.openrouter;
expect(openrouter).toBeDefined();
expect(openrouter.modelsFetcher).toBeDefined();
expect(openrouter.passthroughModels).toBe(true);
});

it("should correctly identify all providers that need dynamic model fetching", () => {
const dynamicProviders = Object.values(AI_PROVIDERS).filter(p => p.modelsFetcher);
const dynamicIds = dynamicProviders.map(p => p.id);

expect(dynamicIds).toContain("kilocode");
expect(dynamicIds).toContain("opencode");
expect(dynamicIds).toContain("openrouter");
});
});
27 changes: 27 additions & 0 deletions tests/unit/gemini-helper-schema.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { cleanJSONSchemaForAntigravity } from '../../open-sse/translator/helpers/geminiHelper.js';

describe('cleanJSONSchemaForAntigravity', () => {
it('should NOT strip `pattern` or `required` from a valid schema', () => {
const schema = {
type: 'object',
properties: {
pattern: {
type: 'string',
description: 'The glob pattern.'
},
path: {
type: 'string',
description: 'The path.'
}
},
required: ['pattern']
};

const cleanedSchema = cleanJSONSchemaForAntigravity(JSON.parse(JSON.stringify(schema)));

expect(cleanedSchema.properties.pattern).toBeDefined();
expect(cleanedSchema.properties.pattern.type).toBe('string');
expect(cleanedSchema.required).toEqual(['pattern']);
});
});