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
2 changes: 1 addition & 1 deletion .github/workflows/daily-model-inventory.lock.yml

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

7 changes: 7 additions & 0 deletions .github/workflows/daily-model-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,13 @@ Use the Copilot reflect endpoint (`billing.multiplier`) and the docs pricing tab
sources for `models.json` pricing fields. Prefer reflect data when available for Copilot model
multiplier validation, and use docs table values as a secondary cross-check.

Also validate Copilot SDK routing metadata in `models.json` for `github-copilot` models:
- `provider_type` (for SDK provider selection)
- `wire_api` when present (`responses` or `completions`, for OpenAI/Azure-compatible transport selection)

When updating `models.json`, preserve or add `wire_api` for Copilot models where source data
provides it. Keep `wire_api` absent for models/providers where it is not applicable.

Treat `gpt-4o-mini`, `gpt-4.1`, `gpt-4o`, and `gpt-5.4-nano` as intentionally deprecated
Copilot-facing model IDs. Keep ignoring them even if they appear in the reflect data, docs table,
`models.dev`, or live provider inventories: do not propose adding or restoring them in
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,7 @@ refresh-models-json:
src=$$(mktemp); \
trap 'rm -f "$$tmp" "$$src"' EXIT; \
curl -fsSL "$(MODELS_DEV_MODELS_JSON_URL)" -o "$$src"; \
jq '{providers: ((.providers // {}) | with_entries(select(.key | test("^(anthropic|openai|github-copilot)$$"))) | with_entries(.value |= {models: ((.models // {}) | with_entries(.value |= {cost: ((.cost // {}) | with_entries(select(.value != null and ((.value | type) == "number" or (.value | type) == "string"))) | with_entries(if (.value | type) == "number" then .value |= (./1000000 | tostring) else . end))}) )}))}' "$$src" > "$$tmp"; \
jq '{providers: ((.providers // {}) | with_entries(select(.key | test("^(anthropic|openai|github-copilot)$$"))) | with_entries(.value |= {models: ((.models // {}) | with_entries(.value |= ({cost: ((.cost // {}) | with_entries(select(.value != null and ((.value | type) == "number" or (.value | type) == "string"))) | with_entries(if (.value | type) == "number" then .value |= (./1000000 | tostring) else . end))} + (if (.provider_type | type) == "string" then {provider_type: .provider_type} else {} end) + (if (.wire_api | type) == "string" then {wire_api: .wire_api} elif (.wireApi | type) == "string" then {wire_api: .wireApi} else {} end))) )}))}' "$$src" > "$$tmp"; \
cp "$$tmp" pkg/cli/data/models.json; \
cp "$$tmp" actions/setup/js/models.json; \
echo "✓ Refreshed pkg/cli/data/models.json and actions/setup/js/models.json (catalog providers: anthropic, openai, github-copilot)"
Expand Down
130 changes: 105 additions & 25 deletions actions/setup/js/awf_reflect.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,52 @@ function isOpenAIModelName(model) {
return model.startsWith("gpt-") || /^o[134][-.]/.test(model) || model === "o1" || model === "o3" || model === "o4";
}

/**
* Look up a model entry in the models.json catalog, case-insensitively.
*
* @param {object | null | undefined} modelsJson
* @param {string} modelName
* @param {string | null | undefined} [providerName]
* @returns {object | null}
*/
function getCatalogModelEntry(modelsJson, modelName, providerName) {
const model = String(modelName || "")
.toLowerCase()
.trim();
const provider = String(providerName || "")
.toLowerCase()
.trim();
if (!model || modelsJson == null || typeof modelsJson !== "object" || Array.isArray(modelsJson)) {
return null;
}
const providers = modelsJson.providers;
if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
return null;
}
const providerEntries = provider
? Object.entries(providers).filter(
([name]) =>
String(name || "")
.toLowerCase()
.trim() === provider
)
: Object.entries(providers);
for (const [, providerData] of providerEntries) {
const models = providerData && typeof providerData === "object" ? providerData.models : null;
if (!models || typeof models !== "object" || Array.isArray(models)) continue;
for (const [catalogModel, catalogEntry] of Object.entries(models)) {
if (
String(catalogModel || "")
.toLowerCase()
.trim() === model
) {
return catalogEntry && typeof catalogEntry === "object" && !Array.isArray(catalogEntry) ? catalogEntry : null;
}
}
}
return null;
}

/**
* Infer the Copilot SDK provider type for a given endpoint provider name and model name.
*
Expand All @@ -360,10 +406,10 @@ function isOpenAIModelName(model) {
*
* @param {string} endpointProvider - The `provider` field from the AWF reflect endpoint entry.
* @param {string} modelName - The resolved model name to use for heuristic fallback.
* @param {object | null | undefined} modelsJson - Parsed models.json catalog (optional).
* @param {object | null | undefined} catalogEntryOrModelsJson - Matching models.json catalog entry or full catalog (optional).
* @returns {"openai" | "azure" | "anthropic"}
*/
function inferProviderTypeForModel(endpointProvider, modelName, modelsJson) {
function inferProviderTypeForModel(endpointProvider, modelName, catalogEntryOrModelsJson) {
// 1. Endpoint provider name mapping.
const ep = String(endpointProvider || "")
.toLowerCase()
Expand All @@ -376,27 +422,15 @@ function inferProviderTypeForModel(endpointProvider, modelName, modelsJson) {
const model = String(modelName || "")
.toLowerCase()
.trim();
const catalogEntry =
catalogEntryOrModelsJson && typeof catalogEntryOrModelsJson === "object" && !Array.isArray(catalogEntryOrModelsJson) && "providers" in catalogEntryOrModelsJson
? getCatalogModelEntry(catalogEntryOrModelsJson, model)
: catalogEntryOrModelsJson;

// 2. Model catalog lookup.
if (modelsJson != null && model) {
const providers = modelsJson && typeof modelsJson === "object" && !Array.isArray(modelsJson) ? modelsJson.providers : null;
if (providers && typeof providers === "object") {
for (const providerData of Object.values(providers)) {
const models = providerData && typeof providerData === "object" ? providerData.models : null;
if (models && typeof models === "object") {
for (const [catalogModel, catalogEntry] of Object.entries(models)) {
if (
String(catalogModel || "")
.toLowerCase()
.trim() === model
) {
const pt = catalogEntry && typeof catalogEntry.provider_type === "string" ? catalogEntry.provider_type.trim() : "";
if (pt === "anthropic" || pt === "azure" || pt === "openai") return /** @type {"openai" | "azure" | "anthropic"} */ pt;
}
}
}
}
}
if (model) {
const pt = catalogEntry && typeof catalogEntry.provider_type === "string" ? catalogEntry.provider_type.trim() : "";
if (pt === "anthropic" || pt === "azure" || pt === "openai") return /** @type {"openai" | "azure" | "anthropic"} */ pt;
}

// 3. Well-known model name heuristics.
Expand All @@ -409,6 +443,41 @@ function inferProviderTypeForModel(endpointProvider, modelName, modelsJson) {
return "openai";
}

/**
* Infer the SDK wire API for a model.
*
* Resolution order:
* 1. For Anthropic provider types: undefined (wireApi ignored by SDK).
* 2. `models.json` explicit `wire_api`/`wireApi`.
* 3. Heuristic default for OpenAI/Azure-compatible models: "completions".
Comment thread
Copilot marked this conversation as resolved.
*
* @param {"openai" | "azure" | "anthropic"} providerType
* @param {string} modelName
* @param {object | null | undefined} catalogEntryOrModelsJson
* @returns {"completions" | "responses" | undefined}
*/
function inferWireApiForModel(providerType, modelName, catalogEntryOrModelsJson) {
if (providerType === "anthropic") {
return undefined;
}
const model = String(modelName || "").trim();
if (!model) return undefined;
const catalogEntry =
catalogEntryOrModelsJson && typeof catalogEntryOrModelsJson === "object" && !Array.isArray(catalogEntryOrModelsJson) && "providers" in catalogEntryOrModelsJson
? getCatalogModelEntry(catalogEntryOrModelsJson, model)
: catalogEntryOrModelsJson;
// Keep the camelCase fallback for defensive compatibility with injected catalog
// objects that bypass the normalized models.json pipeline.
const rawWireApi = typeof catalogEntry?.wire_api === "string" ? catalogEntry.wire_api : typeof catalogEntry?.wireApi === "string" ? catalogEntry.wireApi : "";
const normalizedWireApi = String(rawWireApi || "")
.toLowerCase()
.trim();
if (normalizedWireApi === "responses" || normalizedWireApi === "completions") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The resolution order between catalog wire_api and the Anthropic undefined fallback is not covered by a test — an Anthropic model with an explicit wire_api: "responses" in models.json would return "responses" here (catalog wins), which may or may not be intentional.

The PR description only documents the case where Anthropic is "omitted", but the code's branch ordering allows a catalog entry to override that.

💡 Suggested test to pin the decision
it("catalog wire_api wins over anthropic undefined fallback", () => {
  const reflectData = {
    endpoints: [{ provider: "anthropic", port: 10001, configured: true, models: ["claude-future"] }],
  };
  const modelsJson = {
    providers: {
      "github-copilot": { models: { "claude-future": { provider_type: "anthropic", wire_api: "responses", cost: {} } } },
    },
  };
  expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "claude-future", modelsJson })).toEqual({
    model: "claude-future",
    // Assert whether wireApi: "responses" should be present or absent for Anthropic
    provider: { type: "anthropic", baseUrl: "(apiproxy/redacted) },
  });
});

If the catalog should never override the Anthropic fallback, swap the branch order or add an early guard.

@copilot please address this.

return /** @type {"responses" | "completions"} */ normalizedWireApi;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Anthropic guard is bypassed when catalog has an explicit wire_api value — if any catalog entry sets wire_api: "completions" for an Anthropic model, the function returns "completions" and never reaches the providerType === "anthropic" check on line 455, violating the JSDoc contract and potentially injecting a wireApi field the Anthropic SDK is supposed to ignore (or could reject).

💡 Suggested fix

Move the Anthropic guard before the catalog lookup so it is unconditional:

function inferWireApiForModel(providerType, modelName, modelsJson) {
  // Anthropic SDK ignores wireApi; never emit it for Anthropic providers.
  if (providerType === "anthropic") return undefined;

  const model = String(modelName || "").trim();
  if (!model) return undefined;
  const catalogEntry = getCatalogModelEntry(modelsJson, model);
  const rawWireApi =
    catalogEntry && typeof catalogEntry.wire_api === "string"
      ? catalogEntry.wire_api
      : catalogEntry && typeof catalogEntry.wireApi === "string"
      ? catalogEntry.wireApi
      : "";
  const normalizedWireApi = String(rawWireApi || "").toLowerCase().trim();
  if (normalizedWireApi === "responses" || normalizedWireApi === "completions") {
    return normalizedWireApi;
  }
  return "completions";
}

This ensures no catalog misconfiguration can override the Anthropic constraint.

}
return "completions";
}

/**
* Resolve Copilot SDK BYOK custom provider configuration from AWF /reflect data.
* Chooses a configured endpoint and maps it to a provider base URL and type.
Expand All @@ -424,7 +493,7 @@ function inferProviderTypeForModel(endpointProvider, modelName, modelsJson) {
* modelsJson?: object | null,
* logger?: (msg: string) => void,
* }} [options]
* @returns {{ model: string, provider: { type: "openai" | "azure" | "anthropic", baseUrl: string } } | null}
* @returns {{ model: string, provider: { type: "openai" | "azure" | "anthropic", baseUrl: string, wireApi?: "completions" | "responses" } } | null}
*/
function resolveCopilotSDKCustomProviderFromReflect(options) {
const configuredModel = typeof options?.model === "string" ? options.model.trim() : "";
Expand Down Expand Up @@ -475,11 +544,20 @@ function resolveCopilotSDKCustomProviderFromReflect(options) {
return null;
}

const providerType = inferProviderTypeForModel(String(endpoint.provider || ""), model, options?.modelsJson ?? null);
logger(`sdk-mode: custom provider resolved from awf-reflect (provider=${String(endpoint.provider || "unknown")} type=${providerType} baseUrl=${baseUrl} model=${model})`);
const endpointProvider = String(endpoint.provider || "");
const catalogProviderName =
String(endpointProvider || "")
.toLowerCase()
.trim() === "copilot"
? "github-copilot"
: endpointProvider;
const catalogEntry = getCatalogModelEntry(options?.modelsJson ?? null, model, catalogProviderName);
const providerType = inferProviderTypeForModel(endpointProvider, model, catalogEntry);
const wireApi = inferWireApiForModel(providerType, model, catalogEntry);
logger(`sdk-mode: custom provider resolved from awf-reflect (provider=${String(endpoint.provider || "unknown")} type=${providerType} baseUrl=${baseUrl} model=${model}${wireApi ? ` wireApi=${wireApi}` : ""})`);
return {
model,
provider: { type: providerType, baseUrl },
provider: { type: providerType, baseUrl, ...(wireApi ? { wireApi } : {}) },
};
}

Expand All @@ -497,7 +575,9 @@ if (typeof module !== "undefined" && module.exports) {
extractModelIds,
fetchAWFReflect,
fetchModelsFromUrl,
getCatalogModelEntry,
inferProviderTypeForModel,
inferWireApiForModel,
resolveCopilotSDKCustomProviderFromReflect,
};
}
122 changes: 118 additions & 4 deletions actions/setup/js/awf_reflect.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ const {
extractModelIds,
fetchAWFReflect,
fetchModelsFromUrl,
getCatalogModelEntry,
inferProviderTypeForModel,
inferWireApiForModel,
resolveCopilotSDKCustomProviderFromReflect,
} = require("./awf_reflect.cjs");

Expand Down Expand Up @@ -396,14 +398,80 @@ describe("awf_reflect.cjs", () => {
});
});

describe("getCatalogModelEntry", () => {
it("matches model names case-insensitively", () => {
const entry = getCatalogModelEntry(
{
providers: {
"github-copilot": { models: { "gpt-5.5": { provider_type: "openai", wire_api: "responses", cost: {} } } },
},
},
"GPT-5.5"
);
expect(entry).toEqual({ provider_type: "openai", wire_api: "responses", cost: {} });
});

it("uses the requested provider when duplicate model names exist", () => {
const modelsJson = {
providers: {
openai: { models: { "gpt-5.5": { provider_type: "openai", cost: {} } } },
"github-copilot": { models: { "gpt-5.5": { provider_type: "openai", wire_api: "responses", cost: {} } } },
},
};
expect(getCatalogModelEntry(modelsJson, "gpt-5.5", "github-copilot")).toEqual({
provider_type: "openai",
wire_api: "responses",
cost: {},
});
expect(getCatalogModelEntry(modelsJson, "gpt-5.5", "openai")).toEqual({
provider_type: "openai",
cost: {},
});
});

it("returns null for invalid catalog entries", () => {
expect(
getCatalogModelEntry(
{
providers: {
"github-copilot": { models: { broken: null, arrayish: [] } },
},
},
"broken"
)
).toBeNull();
expect(
getCatalogModelEntry(
{
providers: {
"github-copilot": { models: { broken: null, arrayish: [] } },
},
},
"arrayish"
)
).toBeNull();
});
});

describe("inferWireApiForModel", () => {
it("omits wireApi for Anthropic providers even when the catalog requests one", () => {
expect(inferWireApiForModel("anthropic", "claude-opus-5", { wire_api: "responses" })).toBeUndefined();
});

it("falls back to completions when the catalog value is invalid or absent", () => {
expect(inferWireApiForModel("openai", "gpt-5.5", { wire_api: "grpc" })).toBe("completions");
expect(inferWireApiForModel("openai", "gpt-5.5", null)).toBe("completions");
});
});

describe("resolveCopilotSDKCustomProviderFromReflect", () => {
it("resolves provider baseUrl and model from port when models_url is absent", () => {
const reflectData = {
endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-5.4", "claude-sonnet-4.6"] }],
};
expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData })).toEqual({
model: "gpt-5.4",
provider: { type: "openai", baseUrl: "http://api-proxy:10002" },
provider: { type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "completions" },
});
});

Expand Down Expand Up @@ -439,7 +507,7 @@ describe("awf_reflect.cjs", () => {
};
expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData })).toEqual({
model: "gpt-4o",
provider: { type: "openai", baseUrl: "http://172.30.0.30:10002" },
provider: { type: "openai", baseUrl: "http://172.30.0.30:10002", wireApi: "completions" },
});
});

Expand Down Expand Up @@ -469,7 +537,7 @@ describe("awf_reflect.cjs", () => {
};
expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "gpt-5.4" })).toEqual({
model: "gpt-5.4",
provider: { type: "openai", baseUrl: "http://api-proxy:10002" },
provider: { type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "completions" },
});
});

Expand All @@ -484,7 +552,53 @@ describe("awf_reflect.cjs", () => {
};
expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, modelsJson })).toEqual({
model: "raptor-mini",
provider: { type: "openai", baseUrl: "http://api-proxy:10002" },
provider: { type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "completions" },
});
});

it("uses wire_api from modelsJson when provided", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Only one new test was added for inferWireApiForModel() behavior, and it's exercised indirectly through resolveCopilotSDKCustomProviderFromReflect. The three resolution paths and their edge cases lack direct unit tests.

Missing coverage:

  • wire_api value that isn't "responses" or "completions" (e.g. "grpc") → should silently fall through to the heuristic default
  • modelsJson is null → non-Anthropic model should still return "completions"
  • Anthropic provider with no catalog entry → should return undefined
💡 Suggested test structure (export `inferWireApiForModel` or test via the resolver)
describe("inferWireApiForModel (via resolver)", () => {
  it("returns completions for openai model with no catalog entry", () => {
    const result = resolveCopilotSDKCustomProviderFromReflect({
      reflectData: { endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["new-model"] }] },
      model: "new-model",
      modelsJson: null,
    });
    expect(result.provider.wireApi).toBe("completions");
  });

  it("ignores unrecognized wire_api value and falls back to heuristic", () => {
    const modelsJson = {
      providers: { "github-copilot": { models: { "gpt-x": { provider_type: "openai", wire_api: "grpc", cost: {} } } } },
    };
    const result = resolveCopilotSDKCustomProviderFromReflect({
      reflectData: { endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-x"] }] },
      model: "gpt-x",
      modelsJson,
    });
    expect(result.provider.wireApi).toBe("completions"); // falls through to heuristic
  });
});

@copilot please address this.

const reflectData = {
endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-5.5"] }],
};
const modelsJson = {
providers: {
"github-copilot": { models: { "gpt-5.5": { provider_type: "openai", wire_api: "responses", cost: {} } } },
},
};
expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "gpt-5.5", modelsJson })).toEqual({
model: "gpt-5.5",
provider: { type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "responses" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing test: Anthropic model with explicit catalog wire_api should not produce wireApi in provider config — the existing test suite verifies the happy path (wire_api: "responses"wireApi: "responses") but has no case where an Anthropic catalog entry has an explicit wire_api. This gap means the bug in inferWireApiForModel (catalog check before Anthropic guard) is untestable with the current suite.

💡 Suggested test to add
it("omits wireApi for Anthropic models even when catalog has wire_api", () => {
  const reflectData = {
    endpoints: [{ provider: "anthropic", port: 10002, configured: true, models: ["claude-opus-5"] }],
  };
  const modelsJson = {
    providers: {
      "github-copilot": {
        models: { "claude-opus-5": { provider_type: "anthropic", wire_api: "completions", cost: {} } },
      },
    },
  };
  const result = resolveCopilotSDKCustomProviderFromReflect({
    reflectData,
    model: "claude-opus-5",
    modelsJson,
  });
  expect(result?.provider.wireApi).toBeUndefined();
});

});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] getCatalogModelEntry() is a new, independently useful helper with several non-trivial behaviors, but it has no direct unit tests — only indirect coverage through higher-level resolver tests.

Edge cases worth pinning:

  • Model name matched case-insensitively ("GPT-5.5" matches "gpt-5.5")
  • Model name found in a secondary provider (not just the first one iterated)
  • modelsJson with a model entry that is null or an array → should return null
  • modelsJson is undefined or null → should return null gracefully

Direct tests here would also guard against accidental breakage if getCatalogModelEntry is later refactored.

@copilot please address this.


it("prefers github-copilot catalog metadata when duplicate model names exist across providers", () => {
const reflectData = {
endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-5.5"] }],
};
const modelsJson = {
providers: {
openai: { models: { "gpt-5.5": { provider_type: "openai", cost: {} } } },
"github-copilot": { models: { "gpt-5.5": { provider_type: "openai", wire_api: "responses", cost: {} } } },
},
};
expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "gpt-5.5", modelsJson })).toEqual({
model: "gpt-5.5",
provider: { type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "responses" },
});
});

it("omits wireApi for Anthropic models even when the catalog has wire_api", () => {
const reflectData = {
endpoints: [{ provider: "anthropic", port: 10001, configured: true, models: ["claude-opus-5"] }],
};
const modelsJson = {
providers: {
"github-copilot": { models: { "claude-opus-5": { provider_type: "anthropic", wire_api: "responses", cost: {} } } },
},
};
expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "claude-opus-5", modelsJson })).toEqual({
model: "claude-opus-5",
provider: { type: "anthropic", baseUrl: "http://api-proxy:10001" },
});
});

Expand Down
Loading
Loading