Skip to content
Open
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
16 changes: 16 additions & 0 deletions docs/provider-cost-and-degradation.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,22 @@ says that no scores or verdict were created. Browser fallback may summarize
local test progress only for a session that never reached an interviewer, and
never presents canned feedback as an agent evaluation.

Before an interview on a server that hosts its own interviewer, the lobby says
above Start that Gemini Live usage is billed to this server's Google AI project
on the whole context of the session, and names what reaches it: voice and video
for a coding interview with candidate video enabled, voice for one without, and
voice and board images at a whiteboard, where the camera is off but every board
stays in the context as a realtime image (see
[What a whiteboard interview adds](#what-a-whiteboard-interview-adds)). A coding
interview with example drawing checked also names the drawing, sent whenever
Jim reads it: each `read_board` call sends the current example JPEG, which then
stays in the context like any other image. The
Setup page says the same beside the Google API key. `/api/session` reports
`candidateVideo` from the local agent config and `null` on the web half of a
split deployment, whose agent's settings it cannot see; the lobby shows no
notice while it is `null`, including before the answer and when the request
fails.

## Operating it

Watch `codetrial dispatch_refused ... reason=at_capacity` and
Expand Down
4 changes: 4 additions & 0 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ impl RoomDispatcher for LocalDispatcher {
true
}

fn candidate_video(&self) -> Option<bool> {
Some(self.config.gemini_candidate_video_enabled)
}

fn ensure_agent(&self, room_name: &str, provider: &Provider) -> Result<(), DispatchRefusal> {
self.ensure_agent_with_kind(room_name, provider, false, None)
}
Expand Down
9 changes: 9 additions & 0 deletions src/web/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,13 @@ pub(crate) async fn session_handler(
// because the duration row is live before anyone signs in.
let max_duration_min =
super::token::duration_ceiling(state.config.recording.as_ref().map(|it| it.max_minutes));

// Null on the web half of a split deployment, where the lobby keeps its
// billing notice hidden rather than describe an agent it cannot see.
let candidate_video = state
.dispatcher
.as_ref()
.and_then(|dispatcher| dispatcher.candidate_video());
match current_user(state.accounts.as_ref(), request.headers()).await {
Ok(Some(user)) => json_response(
StatusCode::OK,
Expand All @@ -325,6 +332,7 @@ pub(crate) async fn session_handler(
"loginRequired": login_required,
"guestModeAvailable": state.guest_mode_available(),
"maxDurationMin": max_duration_min,
"candidateVideo": candidate_video,
"user": {
"login": user.login,
"avatarUrl": user.avatar_url
Expand All @@ -338,6 +346,7 @@ pub(crate) async fn session_handler(
"loginRequired": login_required,
"guestModeAvailable": state.guest_mode_available(),
"maxDurationMin": max_duration_min,
"candidateVideo": candidate_video,
}),
),
Err(_) => json_response(
Expand Down
11 changes: 11 additions & 0 deletions src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,17 @@ pub trait RoomDispatcher: Send + Sync + 'static {
false
}

/// Whether the interviewer this dispatcher starts sends candidate video to
/// Gemini, so the lobby can say what a coding interview is billed on.
///
/// `None` unless the dispatcher runs the interviewer from a config it
/// holds. An agent that joins from another host reads its own environment,
/// and the web side guessing at it would put a promise in front of the
/// candidate that nothing here can keep.
fn candidate_video(&self) -> Option<bool> {
None
}

fn ensure_guest_agent(
&self,
_room_name: &str,
Expand Down
1 change: 1 addition & 0 deletions src/web/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ const SETUP_PAGE: &str = r#"<!doctype html>
<fieldset>
<legend>Google AI <span class="requirement">Optional</span></legend>
<p class="group-note">Add a key if this server will host the interviewer.</p>
<p class="group-note">Gemini Live usage is billed to this server's Google AI project, on the whole context of the session: retained audio and images cost again on every later turn.</p>
<div class="field">
<label for="googleApiKey">Google API Key</label>
<input id="googleApiKey" name="googleApiKey" type="password">
Expand Down
195 changes: 195 additions & 0 deletions tests/browser/lobby.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7772,3 +7772,198 @@ lobbyTest(
}
},
);

/// Loads the lobby and waits until `/api/session` has been applied. The
/// status starts as "Checking account..." in the markup, so only the text the
/// answer writes says the notice has been decided; any non-empty text would
/// pass before the response arrived.
async function lobbyAfterSession(page, status) {
const answered = page.waitForResponse(
(response) => new URL(response.url()).pathname === "/api/session",
);
await page.goto(`${base}/`, { waitUntil: "domcontentloaded" });
await answered;
await page.waitForFunction(
(expected) =>
document.querySelector("#account-status").textContent === expected,
status,
);
}

const BILLED_TO_SERVER =
"Gemini Live usage is billed to this server's Google AI project, on the whole context of the session.";

lobbyTest(
"the billing notice names voice and video for a coding interview with candidate video",
async (page) => {
session = {
signedIn: true,
user: { login: "candidate" },
candidateVideo: true,
};
await lobbyAfterSession(page, "Signed in as candidate");
const note = page.locator("#cost-note");
assert.equal(await note.isVisible(), true);
assert.equal(
await note.textContent(),
`This interview sends your voice and video to Gemini Live. ${BILLED_TO_SERVER}`,
);
assert.equal(
await page.getAttribute("#start", "aria-describedby"),
"cost-note",
);
},
);

lobbyTest(
"the billing notice names voice alone for a coding interview without candidate video",
async (page) => {
session = { signedIn: false, candidateVideo: false };
await lobbyAfterSession(page, "Signed out");
const note = page.locator("#cost-note");
assert.equal(await note.isVisible(), true);
assert.equal(
await note.textContent(),
`This interview sends your voice to Gemini Live. ${BILLED_TO_SERVER}`,
);
},
);

for (const candidateVideo of [true, false]) {
lobbyTest(
`the billing notice names board images at a whiteboard (candidate video ${candidateVideo})`,
async (page) => {
session = {
signedIn: true,
user: { login: "candidate" },
candidateVideo,
};
await lobbyAfterSession(page, "Signed in as candidate");
const note = page.locator("#cost-note");
const coding = `This interview sends ${
candidateVideo ? "your voice and video" : "your voice"
} to Gemini Live. ${BILLED_TO_SERVER}`;
assert.equal(await note.textContent(), coding);

await page.locator('[data-mode="whiteboard"]').click();
assert.equal(await note.isVisible(), true);
assert.equal(
await note.textContent(),
`This interview sends your voice and board images to Gemini Live. ${BILLED_TO_SERVER}`,
);

await page.locator('[data-mode="coding"]').click();
assert.equal(await note.textContent(), coding);
},
);
}

// A web-only server answers null, an older one leaves the field out, and a
// failed request means offline practice: none of them can say who is billed.
for (const [name, setup, status] of [
[
"is null",
() => (session = { signedIn: false, candidateVideo: null }),
"Signed out",
],
[
"is missing",
() => (session = { signedIn: true, user: { login: "candidate" } }),
"Signed in as candidate",
],
["request fails", () => failing.add("/api/session"), "Signed out"],
]) {
lobbyTest(
`the billing notice stays hidden when candidateVideo ${name}`,
async (page) => {
setup();
await lobbyAfterSession(page, status);
assert.equal(await page.locator("#cost-note").isVisible(), false);
await page.locator('[data-mode="whiteboard"]').click();
assert.equal(await page.locator("#cost-note").isVisible(), false);
},
);
}

lobbyTest(
"a failed account refresh takes down the billing notice",
async (page) => {
session = {
signedIn: true,
user: { login: "candidate" },
candidateVideo: true,
};
await lobbyAfterSession(page, "Signed in as candidate");
assert.equal(await page.locator("#cost-note").isVisible(), true);

failing.add("/api/session");
const answered = page.waitForResponse(
(response) => new URL(response.url()).pathname === "/api/session",
);
await restore(page);
await answered;
await page.waitForFunction(
() =>
document.querySelector("#account-status").textContent === "Signed out",
);
assert.equal(await page.locator("#cost-note").isVisible(), false);
},
);

for (const candidateVideo of [true, false]) {
lobbyTest(
`the billing notice names the coding drawing while example drawing is on (candidate video ${candidateVideo})`,
async (page) => {
session = {
signedIn: true,
user: { login: "candidate" },
candidateVideo,
};
await lobbyAfterSession(page, "Signed in as candidate");
const note = page.locator("#cost-note");
const voice = candidateVideo ? "your voice and video" : "your voice";
const plain = `This interview sends ${voice} to Gemini Live. ${BILLED_TO_SERVER}`;
const drawing = `This interview sends ${voice}, and your drawing whenever Jim reads it, to Gemini Live. ${BILLED_TO_SERVER}`;
assert.equal(await note.textContent(), plain);

await page.locator("#enable-example-drawing").check();
assert.equal(await note.textContent(), drawing);

// The whiteboard's own wording wins while the checkbox is disabled, and
// the drawing comes back with coding because the box stayed checked.
await page.locator('[data-mode="whiteboard"]').click();
assert.equal(
await note.textContent(),
`This interview sends your voice and board images to Gemini Live. ${BILLED_TO_SERVER}`,
);
await page.locator('[data-mode="coding"]').click();
assert.equal(await note.textContent(), drawing);

await page.locator("#enable-example-drawing").uncheck();
assert.equal(await note.textContent(), plain);
},
);
}

lobbyTest(
"a restored example drawing setting is named in the billing notice",
async (page) => {
session = {
signedIn: true,
user: { login: "candidate" },
candidateVideo: false,
};
await page.addInitScript(() =>
localStorage.setItem(
"codetrial.interviewSettings",
JSON.stringify({ exampleDrawing: true }),
),
);
await lobbyAfterSession(page, "Signed in as candidate");
assert.equal(await page.isChecked("#enable-example-drawing"), true);
assert.equal(
await page.locator("#cost-note").textContent(),
`This interview sends your voice, and your drawing whenever Jim reads it, to Gemini Live. ${BILLED_TO_SERVER}`,
);
},
);
5 changes: 5 additions & 0 deletions tests/browser/setup-page.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ test("setup shows distinct credential groups without narrow-screen overflow", as
await page.locator("fieldset").last().innerText(),
/Optional/i,
);
// Beside the key, so whoever enters it learns whose project pays.
assert.match(
await page.locator("fieldset").last().innerText(),
/Gemini Live usage is billed to this server's Google AI project, on the whole context of the session/,
);
assert.equal(await page.locator("input:required").count(), 3);
assert.equal(await page.locator("#googleApiKey:required").count(), 0);
assert.equal(
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,3 +600,25 @@ async fn local_dispatcher_enforces_guest_admission_capacity() {
Err(DispatchRefusal::AtCapacity),
);
}

/// The lobby's billing notice names video only on this answer, so it has to
/// be the setting the local interviewer will run with, both ways.
#[tokio::test]
async fn candidate_video_reports_the_local_agent_setting() {
for (value, expected) in [("true", true), ("false", false)] {
let dispatcher = LocalDispatcher {
config: crate::config::load_from_pairs([
("LIVEKIT_URL", "wss://primary.example"),
("LIVEKIT_API_KEY", "primary-key"),
("LIVEKIT_API_SECRET", "primary-secret"),
("GOOGLE_API_KEY", "primary-google"),
("CODETRIAL_GEMINI_CANDIDATE_VIDEO_ENABLED", value),
])
.unwrap(),
runtime: tokio::runtime::Handle::current(),
state: Arc::default(),
max_concurrent: 1,
};
assert_eq!(dispatcher.candidate_video(), Some(expected), "{value}");
}
}
Loading