niahere 0.5.5 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/auth.ts +28 -3
- package/src/agent/backends/claude-normalize.ts +6 -0
- package/src/agent/backends/claude.ts +9 -0
- package/src/agent/credentials.ts +77 -0
- package/src/chat/engine.ts +3 -0
- package/src/core/engine-guard.ts +29 -5
- package/src/core/runner.ts +3 -0
- package/src/db/models/active_engine.ts +49 -0
- package/src/types/config.ts +5 -0
- package/src/utils/config.ts +9 -0
package/package.json
CHANGED
package/src/agent/auth.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "fs";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import type { ProviderName } from "./models";
|
|
5
|
+
import { resolveClaudeCredential } from "./credentials";
|
|
6
|
+
import { getConfig } from "../utils/config";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Provider sign-in state, read from whatever each CLI stores on disk.
|
|
@@ -43,6 +45,17 @@ const defaultReader: AuthReader = {
|
|
|
43
45
|
env: (k) => process.env[k],
|
|
44
46
|
};
|
|
45
47
|
|
|
48
|
+
/** Config is optional here: a health check must never fail because config is
|
|
49
|
+
* unreadable, it must say so. */
|
|
50
|
+
function credentialConfig(): { anthropic_oauth_token: string | null; anthropic_api_key: string | null } {
|
|
51
|
+
try {
|
|
52
|
+
const c = getConfig();
|
|
53
|
+
return { anthropic_oauth_token: c.anthropic_oauth_token, anthropic_api_key: c.anthropic_api_key };
|
|
54
|
+
} catch {
|
|
55
|
+
return { anthropic_oauth_token: null, anthropic_api_key: null };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
export function claudeCredentialsPath(): string {
|
|
47
60
|
return join(homedir(), ".claude", ".credentials.json");
|
|
48
61
|
}
|
|
@@ -62,8 +75,16 @@ function ago(ms: number): string {
|
|
|
62
75
|
export function claudeAuthStatus(now: number = Date.now(), reader: AuthReader = defaultReader): AuthStatus {
|
|
63
76
|
const base = { provider: "claude" as const };
|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
// Ask the same resolver the backend uses. Reading the environment here while
|
|
79
|
+
// the backend also reads config meant the check could report one credential
|
|
80
|
+
// while a different one served the turn — the exact ambiguity this exists to
|
|
81
|
+
// remove.
|
|
82
|
+
const credential = resolveClaudeCredential(credentialConfig(), reader.env);
|
|
83
|
+
if (credential.kind === "oauth_token") {
|
|
84
|
+
return { ...base, state: "ok", detail: "configured oauth token (subscription, no refresh needed)" };
|
|
85
|
+
}
|
|
86
|
+
if (credential.kind === "api_key") {
|
|
87
|
+
return { ...base, state: "ok", detail: "configured API key (metered — billed per token)" };
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
const path = claudeCredentialsPath();
|
|
@@ -97,7 +118,11 @@ export function claudeAuthStatus(now: number = Date.now(), reader: AuthReader =
|
|
|
97
118
|
return { ...status, state: "stale", detail: `access token lapsed ${ago(now - access)} ago, renewable${plan}` };
|
|
98
119
|
}
|
|
99
120
|
if (access !== undefined) {
|
|
100
|
-
return {
|
|
121
|
+
return {
|
|
122
|
+
...status,
|
|
123
|
+
state: "ok",
|
|
124
|
+
detail: `Claude Code login, valid for ${ago(access - now)}${plan} — renewed only when the CLI is used here`,
|
|
125
|
+
};
|
|
101
126
|
}
|
|
102
127
|
return { ...status, state: "unknown", detail: "credentials file carries no expiry" };
|
|
103
128
|
}
|
|
@@ -90,6 +90,7 @@ function attribute(modelUsage: unknown): Record<string, unknown> | undefined {
|
|
|
90
90
|
* stay backend-agnostic.
|
|
91
91
|
*/
|
|
92
92
|
export class SdkNormalizer implements Normalizer {
|
|
93
|
+
private apiKeySource: string | undefined;
|
|
93
94
|
private accumulatedThinking = "";
|
|
94
95
|
private lastThinkingLine = "";
|
|
95
96
|
|
|
@@ -97,6 +98,10 @@ export class SdkNormalizer implements Normalizer {
|
|
|
97
98
|
const msg = message as any;
|
|
98
99
|
|
|
99
100
|
if (msg.type === "system" && msg.subtype === "init") {
|
|
101
|
+
// apiKeySource names which credential served the session ('oauth' is
|
|
102
|
+
// Claude Code's own login). Recording it is what makes a silent switch
|
|
103
|
+
// of credential visible after the fact.
|
|
104
|
+
if (typeof msg.apiKeySource === "string") this.apiKeySource = msg.apiKeySource;
|
|
100
105
|
return [{ type: "session", backendSessionId: msg.session_id }];
|
|
101
106
|
}
|
|
102
107
|
|
|
@@ -211,6 +216,7 @@ export class SdkNormalizer implements Normalizer {
|
|
|
211
216
|
terminal_reason: msg.terminal_reason,
|
|
212
217
|
session_id: msg.session_id,
|
|
213
218
|
subtype: msg.subtype,
|
|
219
|
+
api_key_source: this.apiKeySource,
|
|
214
220
|
usage: msg.usage,
|
|
215
221
|
model_usage: attribute(msg.modelUsage),
|
|
216
222
|
},
|
|
@@ -11,6 +11,7 @@ import { MessageStream } from "../message-stream";
|
|
|
11
11
|
import { getSdkSkillsSetting } from "../../core/skills";
|
|
12
12
|
import { getSdkHooks } from "../../core/sdk-hooks";
|
|
13
13
|
import { getConfig } from "../../utils/config";
|
|
14
|
+
import { resolveClaudeCredential, credentialEnv } from "../credentials";
|
|
14
15
|
import { sleep } from "../../utils/retry";
|
|
15
16
|
|
|
16
17
|
/** The shape of the SDK `query()` handle the session consumes. Injected so the
|
|
@@ -106,6 +107,14 @@ class ClaudeSession implements AgentSession {
|
|
|
106
107
|
// same cwd; jobs always run with a unique id and never auto-continued.
|
|
107
108
|
if (this.ctx.interactive) options.continue = false;
|
|
108
109
|
}
|
|
110
|
+
// Hand the CLI a credential Nia owns when one is configured. Without this
|
|
111
|
+
// it inherits ~/.claude/.credentials.json, which only refreshes when a
|
|
112
|
+
// human runs `claude` on this machine — the coupling that had Nia
|
|
113
|
+
// answering as codex for sixteen days.
|
|
114
|
+
const credential = resolveClaudeCredential(getConfig());
|
|
115
|
+
if (credential.envVar) {
|
|
116
|
+
options.env = credentialEnv(credential, process.env as Record<string, string>);
|
|
117
|
+
}
|
|
109
118
|
if (this.ctx.outputSchema) options.outputFormat = { type: "json_schema", schema: this.ctx.outputSchema };
|
|
110
119
|
if (this.ctx.mcpServers) options.mcpServers = this.ctx.mcpServers;
|
|
111
120
|
if (this.ctx.subagents && Object.keys(this.ctx.subagents).length > 0) options.agents = this.ctx.subagents;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Config } from "../types/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which Claude credential the daemon should use, and where it came from.
|
|
5
|
+
*
|
|
6
|
+
* Nia used to have no answer to this: it inherited whatever `claude` had last
|
|
7
|
+
* written to `~/.claude/.credentials.json`, refreshed by a human opening a
|
|
8
|
+
* terminal on the same machine. When that stopped, Nia answered as codex for
|
|
9
|
+
* sixteen days and nothing said why. A credential the daemon is handed
|
|
10
|
+
* explicitly is one it can report on, and one that does not lapse because
|
|
11
|
+
* nobody logged in today.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type CredentialKind = "oauth_token" | "api_key" | "claude_code_login";
|
|
15
|
+
|
|
16
|
+
export interface ClaudeCredential {
|
|
17
|
+
kind: CredentialKind;
|
|
18
|
+
/** The variable the CLI reads it from. Absent for the inherited login. */
|
|
19
|
+
envVar?: "CLAUDE_CODE_OAUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
|
20
|
+
value?: string;
|
|
21
|
+
/**
|
|
22
|
+
* `subscription` rides the plan. `metered` bills per token — worth saying out
|
|
23
|
+
* loud, because Nia has run $552 in a week and that is an invoice on the API.
|
|
24
|
+
*/
|
|
25
|
+
billing: "subscription" | "metered";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every variable a credential could occupy, so switching never leaves a stale one behind. */
|
|
29
|
+
const TOKEN_VARS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] as const;
|
|
30
|
+
|
|
31
|
+
const clean = (v: unknown): string | undefined => {
|
|
32
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
33
|
+
return s.length > 0 ? s : undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type EnvLookup = (key: string) => string | undefined;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Config first, then the ambient environment, then Claude Code's own login.
|
|
40
|
+
* Config wins so the daemon is not at the mercy of whatever shell launched it.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveClaudeCredential(
|
|
43
|
+
config: Pick<Config, "anthropic_oauth_token" | "anthropic_api_key">,
|
|
44
|
+
env: EnvLookup = (k) => process.env[k],
|
|
45
|
+
): ClaudeCredential {
|
|
46
|
+
const oauth = clean(config.anthropic_oauth_token) ?? clean(env("CLAUDE_CODE_OAUTH_TOKEN"));
|
|
47
|
+
if (oauth) {
|
|
48
|
+
return { kind: "oauth_token", envVar: "CLAUDE_CODE_OAUTH_TOKEN", value: oauth, billing: "subscription" };
|
|
49
|
+
}
|
|
50
|
+
const key = clean(config.anthropic_api_key) ?? clean(env("ANTHROPIC_API_KEY"));
|
|
51
|
+
if (key) {
|
|
52
|
+
return { kind: "api_key", envVar: "ANTHROPIC_API_KEY", value: key, billing: "metered" };
|
|
53
|
+
}
|
|
54
|
+
return { kind: "claude_code_login", billing: "subscription" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The environment for the spawned CLI. The base is passed through — it still
|
|
59
|
+
* needs PATH and HOME — with exactly one credential variable set, and any other
|
|
60
|
+
* cleared so a removed credential stops working immediately rather than at the
|
|
61
|
+
* next restart.
|
|
62
|
+
*/
|
|
63
|
+
export function credentialEnv(credential: ClaudeCredential, base: Record<string, string>): Record<string, string> {
|
|
64
|
+
const env: Record<string, string> = {};
|
|
65
|
+
for (const [k, v] of Object.entries(base)) {
|
|
66
|
+
if ((TOKEN_VARS as readonly string[]).includes(k)) continue;
|
|
67
|
+
env[k] = v;
|
|
68
|
+
}
|
|
69
|
+
if (credential.envVar && credential.value) env[credential.envVar] = credential.value;
|
|
70
|
+
return env;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function describeCredential(credential: ClaudeCredential): string {
|
|
74
|
+
if (credential.kind === "oauth_token") return "configured oauth token (subscription, long-lived)";
|
|
75
|
+
if (credential.kind === "api_key") return "configured API key (metered, billed per token)";
|
|
76
|
+
return "Claude Code's own login (refreshed by whoever last used the CLI here)";
|
|
77
|
+
}
|
package/src/chat/engine.ts
CHANGED
|
@@ -291,6 +291,9 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
291
291
|
|
|
292
292
|
try {
|
|
293
293
|
for await (const ev of sess.send(userMessage, attachments)) {
|
|
294
|
+
// Keep the lease alive while the turn runs, so a slow reply is
|
|
295
|
+
// never mistaken for a crashed one.
|
|
296
|
+
void ignore(ActiveEngine.throttledTouch(room), "touch active-engine");
|
|
294
297
|
switch (ev.type) {
|
|
295
298
|
case "session": {
|
|
296
299
|
if (!sessionId || ev.backendSessionId !== sessionId) {
|
package/src/core/engine-guard.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { ActiveEngine } from "../db/models";
|
|
10
|
+
import { isStale, type ActiveEngine as ActiveEngineRow } from "../db/models/active_engine";
|
|
10
11
|
import { withDb } from "../db/with-db";
|
|
11
12
|
import { DIM, RESET, ICON_WARN } from "../utils/cli";
|
|
12
13
|
|
|
@@ -38,21 +39,41 @@ export function withDefaultWait(opts: GuardOptions, defaultWaitMinutes: number):
|
|
|
38
39
|
interface ActiveSummary {
|
|
39
40
|
count: number;
|
|
40
41
|
rooms: string[];
|
|
42
|
+
/** Rows nothing has pinged lately — ignored, but worth saying out loud. */
|
|
43
|
+
stale: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A row only counts as work if something is still pinging it. Without this a
|
|
48
|
+
* crash — or a test pointed at the wrong database — leaves a row that blocks
|
|
49
|
+
* stop, restart and update indefinitely, including the restart whose startup
|
|
50
|
+
* would have cleared it.
|
|
51
|
+
*/
|
|
52
|
+
export function partitionEngines(
|
|
53
|
+
engines: ActiveEngineRow[],
|
|
54
|
+
now: number = Date.now(),
|
|
55
|
+
): { live: ActiveEngineRow[]; stale: ActiveEngineRow[] } {
|
|
56
|
+
const live: ActiveEngineRow[] = [];
|
|
57
|
+
const stale: ActiveEngineRow[] = [];
|
|
58
|
+
for (const e of engines) (isStale(e.lastPing, now) ? stale : live).push(e);
|
|
59
|
+
return { live, stale };
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
async function getActiveEngines(): Promise<ActiveSummary> {
|
|
44
63
|
let count = 0;
|
|
45
64
|
let rooms: string[] = [];
|
|
65
|
+
let stale = 0;
|
|
46
66
|
try {
|
|
47
67
|
await withDb(async () => {
|
|
48
|
-
const
|
|
49
|
-
count =
|
|
50
|
-
rooms =
|
|
68
|
+
const partitioned = partitionEngines(await ActiveEngine.list());
|
|
69
|
+
count = partitioned.live.length;
|
|
70
|
+
rooms = partitioned.live.map((e) => `${e.room} (${e.channel})`);
|
|
71
|
+
stale = partitioned.stale.length;
|
|
51
72
|
});
|
|
52
73
|
} catch {
|
|
53
74
|
// DB unreachable — no engines to worry about
|
|
54
75
|
}
|
|
55
|
-
return { count, rooms };
|
|
76
|
+
return { count, rooms, stale };
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
/**
|
|
@@ -62,7 +83,10 @@ async function getActiveEngines(): Promise<ActiveSummary> {
|
|
|
62
83
|
export async function guardActiveEngines(action: string, opts: GuardOptions): Promise<boolean> {
|
|
63
84
|
if (opts.force) return true;
|
|
64
85
|
|
|
65
|
-
const { count, rooms } = await getActiveEngines();
|
|
86
|
+
const { count, rooms, stale } = await getActiveEngines();
|
|
87
|
+
if (stale > 0) {
|
|
88
|
+
console.log(`${DIM}ignoring ${stale} stale engine row${stale > 1 ? "s" : ""} (no heartbeat)${RESET}`);
|
|
89
|
+
}
|
|
66
90
|
if (count === 0) return true;
|
|
67
91
|
|
|
68
92
|
// Active engines found
|
package/src/core/runner.ts
CHANGED
|
@@ -60,6 +60,9 @@ async function consumeBackendRun(
|
|
|
60
60
|
|
|
61
61
|
try {
|
|
62
62
|
for await (const ev of session.send(prompt)) {
|
|
63
|
+
// Keep the lease alive while the turn runs. Throttled, so a long job
|
|
64
|
+
// stays live without one write per event.
|
|
65
|
+
if (activeRoom) void ignore(ActiveEngine.throttledTouch(activeRoom), "touch active-engine");
|
|
63
66
|
if (ev.type === "thinking") onActivity?.(ev.delta);
|
|
64
67
|
else if (ev.type === "tool") onActivity?.(ev.summary ?? ev.name);
|
|
65
68
|
else if (ev.type === "result") {
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { getSql } from "../connection";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* How often a running turn re-stamps its row. Called from the event loops
|
|
5
|
+
* rather than a timer on purpose: a timer outlives the work it describes, so a
|
|
6
|
+
* process that died mid-turn would keep its row looking alive. A loop that
|
|
7
|
+
* stops simply stops pinging.
|
|
8
|
+
*/
|
|
9
|
+
export const PING_INTERVAL_MS = 30_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Silence longer than this means the turn is gone, not slow — several missed
|
|
13
|
+
* pings, not one. Nothing read this column for a long time, so a row left
|
|
14
|
+
* behind by a crash (or a test aimed at the wrong database) counted as live
|
|
15
|
+
* work forever, and blocked the very restart that clears it.
|
|
16
|
+
*/
|
|
17
|
+
export const STALE_AFTER_MS = 3 * 60_000;
|
|
18
|
+
|
|
3
19
|
export interface ActiveEngine {
|
|
4
20
|
room: string;
|
|
5
21
|
channel: string;
|
|
@@ -21,12 +37,45 @@ export async function ping(room: string): Promise<void> {
|
|
|
21
37
|
await sql`UPDATE active_engines SET last_ping = NOW() WHERE room = ${room}`;
|
|
22
38
|
}
|
|
23
39
|
|
|
40
|
+
/** An unreadable timestamp counts as live: `--force` is the escape hatch, and
|
|
41
|
+
* killing real work is the worse mistake. */
|
|
42
|
+
export function isStale(lastPing: string, now: number = Date.now()): boolean {
|
|
43
|
+
const t = Date.parse(lastPing);
|
|
44
|
+
return Number.isFinite(t) ? now - t > STALE_AFTER_MS : false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const lastTouch = new Map<string, number>();
|
|
48
|
+
|
|
49
|
+
export interface TouchDeps {
|
|
50
|
+
now?: number;
|
|
51
|
+
seen?: Map<string, number>;
|
|
52
|
+
ping?: (room: string) => Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Re-stamp a running turn, at most once per interval. Safe to call from a hot
|
|
56
|
+
* loop — the chat path fires per token. */
|
|
57
|
+
export async function throttledTouch(room: string, deps: TouchDeps = {}): Promise<void> {
|
|
58
|
+
const now = deps.now ?? Date.now();
|
|
59
|
+
const seen = deps.seen ?? lastTouch;
|
|
60
|
+
const previous = seen.get(room);
|
|
61
|
+
if (previous !== undefined && now - previous < PING_INTERVAL_MS) return;
|
|
62
|
+
seen.set(room, now);
|
|
63
|
+
await (deps.ping ?? ping)(room);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Forget a room's throttle state so a later turn pings immediately. */
|
|
67
|
+
export function forgetTouch(room: string): void {
|
|
68
|
+
lastTouch.delete(room);
|
|
69
|
+
}
|
|
70
|
+
|
|
24
71
|
export async function unregister(room: string): Promise<void> {
|
|
72
|
+
forgetTouch(room);
|
|
25
73
|
const sql = getSql();
|
|
26
74
|
await sql`DELETE FROM active_engines WHERE room = ${room}`;
|
|
27
75
|
}
|
|
28
76
|
|
|
29
77
|
export async function clearAll(): Promise<void> {
|
|
78
|
+
lastTouch.clear();
|
|
30
79
|
const sql = getSql();
|
|
31
80
|
await sql`DELETE FROM active_engines`;
|
|
32
81
|
}
|
package/src/types/config.ts
CHANGED
|
@@ -112,6 +112,11 @@ export interface Config {
|
|
|
112
112
|
activeHours: { start: string; end: string };
|
|
113
113
|
database_url: string;
|
|
114
114
|
log_level: string;
|
|
115
|
+
/** Long-lived token from `claude setup-token`. Preferred: it keeps the
|
|
116
|
+
* subscription and does not depend on anyone logging in on this machine. */
|
|
117
|
+
anthropic_oauth_token: string | null;
|
|
118
|
+
/** Metered per-token billing — a different bill from the subscription. */
|
|
119
|
+
anthropic_api_key: string | null;
|
|
115
120
|
gemini_api_key: string | null;
|
|
116
121
|
sessionFinalization: SessionFinalizationConfig;
|
|
117
122
|
channels: ChannelsConfig;
|
package/src/utils/config.ts
CHANGED
|
@@ -15,6 +15,8 @@ const DEFAULTS: Config = {
|
|
|
15
15
|
activeHours: { start: "00:00", end: "23:59" },
|
|
16
16
|
database_url: DEFAULT_DATABASE_URL,
|
|
17
17
|
log_level: "info",
|
|
18
|
+
anthropic_oauth_token: null,
|
|
19
|
+
anthropic_api_key: null,
|
|
18
20
|
gemini_api_key: null,
|
|
19
21
|
sessionFinalization: {
|
|
20
22
|
enabled: true,
|
|
@@ -129,6 +131,11 @@ export function loadConfig(): Config {
|
|
|
129
131
|
const log_level = process.env.LOG_LEVEL || (typeof raw.log_level === "string" ? raw.log_level : DEFAULTS.log_level);
|
|
130
132
|
|
|
131
133
|
// Gemini API key — env var overrides config
|
|
134
|
+
const anthropic_oauth_token =
|
|
135
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
136
|
+
(typeof raw.anthropic_oauth_token === "string" ? raw.anthropic_oauth_token : null);
|
|
137
|
+
const anthropic_api_key =
|
|
138
|
+
process.env.ANTHROPIC_API_KEY || (typeof raw.anthropic_api_key === "string" ? raw.anthropic_api_key : null);
|
|
132
139
|
const gemini_api_key =
|
|
133
140
|
process.env.GEMINI_API_KEY || (typeof raw.gemini_api_key === "string" ? raw.gemini_api_key : null);
|
|
134
141
|
|
|
@@ -256,6 +263,8 @@ export function loadConfig(): Config {
|
|
|
256
263
|
activeHours: { start, end },
|
|
257
264
|
database_url,
|
|
258
265
|
log_level,
|
|
266
|
+
anthropic_oauth_token,
|
|
267
|
+
anthropic_api_key,
|
|
259
268
|
gemini_api_key,
|
|
260
269
|
sessionFinalization,
|
|
261
270
|
channels: {
|