Skip to content

Commit 10b907b

Browse files
CopilotlpcoxCopilot
authored
Model routing: make the selection advisory instead of pinning requests (#9272)
* Initial plan * feat(api-proxy): make task-level model routing advisory Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * refactor(api-proxy): rename routing observation and scope failures to selected model Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * Refactor model normalization and telemetry value retrieval Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test(api-proxy): cover routing model truncation boundaries --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 4efc3ef commit 10b907b

15 files changed

Lines changed: 660 additions & 685 deletions

‎.github/workflows/test-model-routing.yml‎

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ jobs:
160160
YAML
161161
162162
# The agent learns the routed model, effort, and endpoint from /reflect
163-
# and must send exactly that request; anything else is rejected with 403.
163+
# and seeds its request from it. The selection is advisory, not enforced.
164164
ROUTED_COMMAND="$(cat <<'SCRIPT'
165165
set -euo pipefail
166166
test -n "${COPILOT_API_URL:-}" || { echo "COPILOT_API_URL is missing" >&2; exit 1; }
@@ -205,7 +205,7 @@ jobs:
205205
sudo --preserve-env=COPILOT_GITHUB_TOKEN,GITHUB_WORKSPACE,HOME,PATH \
206206
node dist/cli.js --config "${ROUTING_DIR}/awf-config.yml" -- bash -euc "$ROUTED_COMMAND"
207207
208-
- name: Route between two Copilot candidates and enforce the choice
208+
- name: Route between two Copilot candidates and allow a deviating choice
209209
env:
210210
COPILOT_GITHUB_TOKEN: ${{ github.token }}
211211
run: |
@@ -241,7 +241,8 @@ jobs:
241241
YAML
242242
243243
# ROUTING_TARGET=selected sends the routed choice; ROUTING_TARGET=other
244-
# sends the candidate the router did not pick, which must be rejected.
244+
# sends the candidate the router did not pick. The selection is advisory,
245+
# so both must succeed as long as model policy permits the model.
245246
ROUTED_COMMAND="$(cat <<'SCRIPT'
246247
set -euo pipefail
247248
test -n "${COPILOT_API_URL:-}" || { echo "COPILOT_API_URL is missing" >&2; exit 1; }
@@ -272,20 +273,6 @@ jobs:
272273
path="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).path)' "$plan")"
273274
body="$(node -e 'process.stdout.write(JSON.stringify(JSON.parse(process.argv[1]).body))' "$plan")"
274275
target="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).model)' "$plan")"
275-
if [ "${ROUTING_TARGET}" = other ]; then
276-
rejected="$(mktemp)"
277-
status="$(curl --silent --show-error --max-time 60 -o "$rejected" -w '%{http_code}' \
278-
-H "Content-Type: application/json" --data "$body" "${COPILOT_API_URL}${path}")"
279-
node -e '
280-
const assert = require("node:assert/strict");
281-
assert.equal(process.argv[1], "403", "non-selected candidate was not rejected");
282-
const error = JSON.parse(require("node:fs").readFileSync(process.argv[3], "utf8")).error;
283-
assert.equal(error?.code, "model_routing_mismatch");
284-
console.log("ROUTED_ENFORCEMENT=rejected model=" + process.argv[2]);
285-
' "$status" "$target" "$rejected"
286-
rm -f "$rejected"
287-
exit 0
288-
fi
289276
response="$(curl --fail --silent --show-error --max-time 180 \
290277
-H "Content-Type: application/json" --data "$body" "${COPILOT_API_URL}${path}")"
291278
node -e '
@@ -298,8 +285,8 @@ jobs:
298285
.map(part => part.text)
299286
.join("");
300287
assert.match(String(text), /ROUTED_OK/, "Copilot did not return the expected smoke response: " + JSON.stringify(result).slice(0, 500));
301-
console.log("ROUTED_MULTI_CANDIDATE_INFERENCE=passed model=" + process.argv[2]);
302-
' "$response" "$target"
288+
console.log("ROUTED_MULTI_CANDIDATE_INFERENCE=passed target=" + process.argv[3] + " model=" + process.argv[2]);
289+
' "$response" "$target" "$ROUTING_TARGET"
303290
SCRIPT
304291
)"
305292
@@ -308,16 +295,12 @@ jobs:
308295
node dist/cli.js --config "${ROUTING_DIR}/awf-config.yml" --env ROUTING_TARGET=selected -- bash -euc "$ROUTED_COMMAND"
309296
echo "::endgroup::"
310297
311-
echo "::group::Non-selected candidate is rejected"
312-
set +e
298+
echo "::group::Non-selected candidate is admitted (advisory routing)"
299+
# A deviating request is not a routing failure: the run must exit 0.
313300
sudo --preserve-env=COPILOT_GITHUB_TOKEN,GITHUB_WORKSPACE,HOME,PATH \
314301
node dist/cli.js --config "${ROUTING_DIR}/awf-config.yml" --env ROUTING_TARGET=other -- bash -euc "$ROUTED_COMMAND"
315-
status=$?
316-
set -e
317302
echo "::endgroup::"
318-
# A mismatched request is a terminal routing failure, reported as exit 78.
319-
test "$status" -eq 78 || { echo "expected AWF exit 78 after a routing mismatch, got ${status}" >&2; exit 1; }
320-
echo "ROUTED_MISMATCH_EXIT=78"
303+
echo "ROUTED_DEVIATION_EXIT=0"
321304
322305
- name: Install the Copilot CLI used by gh-aw
323306
env:
@@ -364,8 +347,8 @@ jobs:
364347
YAML
365348
366349
# The shape a gh-aw engine step would take: read the routed selection
367-
# from /reflect and hand it to the Copilot CLI. Any request the CLI
368-
# makes with another model or effort is rejected and AWF exits 78.
350+
# from /reflect and hand it to the Copilot CLI. The selection is advisory:
351+
# the CLI (or a sub-agent) may use another policy-permitted model.
369352
ROUTED_COMMAND="$(cat <<'SCRIPT'
370353
set -euo pipefail
371354
test -n "${COPILOT_API_URL:-}" || { echo "COPILOT_API_URL is missing" >&2; exit 1; }

‎containers/api-proxy/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ COPY server.js logging.js metrics.js rate-limiter.js rate-limiter-window.js \
7474
provider-env-constants.js provider-env-constants.json provider-names.js \
7575
model-api-mapping.js model-api-mapping.json \
7676
routing-candidates.js routing-catalogue.js routing-classifier.js routing-config.js \
77-
routing-contract.js routing-controller.js routing-enforcement.js routing-errors.js \
77+
routing-contract.js routing-controller.js routing-errors.js routing-observation.js \
7878
routing-planner.js routing-provider-executor.js routing-router-client.js routing-runtime.js ./
7979
COPY guards/ ./guards/
8080
COPY providers/ ./providers/

‎containers/api-proxy/body-handler.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ function createBodyHandler({ handleRequestError, otel }) {
236236
}
237237
}
238238

239-
const allowSteering = req.awfRequestContext?.purpose !== 'routing_classification' && !req.awfRouting;
239+
const allowSteering = req.awfRequestContext?.purpose !== 'routing_classification';
240240
if (allowSteering && isSteeringEnabled() && (req.method === 'POST' || req.method === 'PUT')) {
241241
const steeringMessages = [
242242
{ type: 'timeout', message: getAndClearPendingTimeoutSteeringMessage() },

‎containers/api-proxy/routing-enforcement.js‎

Lines changed: 0 additions & 237 deletions
This file was deleted.

0 commit comments

Comments
 (0)