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 ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"test:local": "pnpm run test:local:packages && pnpm run test:local:scripts",
"test:local:packages": "node tools/scripts/runLocalTestPackages.mjs",
"test:local:scripts": "node --test tools/scripts/test/*.spec.mjs && pnpm run test:memory-quality",
"test:memory-quality": "node --test tools/memory-maintenance-qualification/quality-*.test.mjs tools/memory-maintenance-qualification/live-resume.test.mjs tools/memory-maintenance-qualification/longmemeval-*.test.mjs tools/memory-maintenance-qualification/ingestion-profile.test.mjs",
"test:memory-quality": "node --test tools/memory-maintenance-qualification/quality-*.test.mjs tools/memory-maintenance-qualification/live-resume.test.mjs tools/memory-maintenance-qualification/longmemeval-*.test.mjs tools/memory-maintenance-qualification/topic-decision-*.test.mjs tools/memory-maintenance-qualification/topic-curation-*.test.mjs tools/memory-maintenance-qualification/ingestion-profile.test.mjs",
"test:ui": "pnpm -r --no-bail --no-sort --stream --if-present run test:ui"
},
"devDependencies": {
Expand Down
3 changes: 3 additions & 0 deletions ts/packages/agentServer/server/src/durableMemoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import {
FileMemoryService,
createKnowProCorpusIndex,
getConfiguredMemoryViewOptions,
type TopicDecisionReceipt,
} from "@typeagent/memory-service";
import { getConfiguredViewModelOptions } from "./viewModelOptions.js";

export function createDurableMemoryService(
rootDirectory: string,
onTopicDecisionReceipt?: (receipt: TopicDecisionReceipt) => void,
): FileMemoryService {
return new FileMemoryService(rootDirectory, {
...getConfiguredMemoryViewOptions(),
...getConfiguredViewModelOptions(),
...(onTopicDecisionReceipt ? { onTopicDecisionReceipt } : {}),
indexFactory: (corpusId, indexDirectory) =>
createKnowProCorpusIndex(corpusId, indexDirectory, () => {
const settings = createDocMemorySettings(
Expand Down
28 changes: 28 additions & 0 deletions ts/packages/aiclient/src/decisionModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type CopilotModelEndpointProvider,
} from "./copilotModels.js";
import { RequestAdmissionError } from "./requestBudget.js";
import { checkSerializedRequestConstraints } from "./requestConstraints.js";
import { context, trace, SpanStatusCode, type Span } from "@opentelemetry/api";
import { TokenCounter } from "./tokenCounter.js";

Expand Down Expand Up @@ -92,6 +93,7 @@ export interface DecisionModel {
| "override"
| "config"
| "default";
checkRequest?(request: DecisionRequest): void;
decide(
request: DecisionRequest,
options?: { signal?: AbortSignal; timeoutMs?: number },
Expand Down Expand Up @@ -149,6 +151,32 @@ export function createDecisionModel(options?: {
model,
configGeneration,
providerSource,
checkRequest(request) {
validateDecisionRequest(request);
if (provider === "azure") {
const serialized = JSON.stringify({ model, ...request });
if (
config.azureOpenAI.maxPromptChars !== undefined &&
serialized.length > config.azureOpenAI.maxPromptChars
)
throw new RequestAdmissionError(
"Decision request exceeds configured character limit",
);
for (const member of pool!.members)
checkSerializedRequestConstraints(
serialized,
member.settings.requestConstraints,
);
} else
checkSerializedRequestConstraints(
JSON.stringify(
buildCopilotDecisionRequest(model, request),
),
config.modelRequestConstraints?.get(
`copilot:decision:${model}`.toLowerCase(),
),
);
},
async decide(request, options) {
if (options?.signal?.aborted) return failure("cancelled");
const timeoutMs =
Expand Down
14 changes: 14 additions & 0 deletions ts/packages/aiclient/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
EmbeddingPart,
EmbeddingRole,
} from "./embeddingIdentity.js";
import type { RequestConstraintReceipt } from "./requestConstraints.js";

/**
* Translation settings for Chat models
Expand Down Expand Up @@ -77,11 +78,24 @@ export type FunctionCallingResult = {

export type CompleteUsageStatsCallback = (usage: CompletionUsageStats) => void;

/** Offline inspection of the non-streaming wire request. Contains prompt/schema; do not log it. */
export interface ChatRequestInspection {
serializedRequest: string;
estimatedInputTokens: number;
tokenizer: "o200k_base";
constraints: RequestConstraintReceipt;
}

/**
* A TypeChat language model with greater control on settings
*/
export interface ChatModel extends TypeChatLanguageModel {
completionSettings: CompletionSettings;
/** Available on supported providers; throws when configured request constraints fail. */
inspectRequest?(
prompt: string | PromptSection[],
jsonSchema?: CompletionJsonSchema,
): ChatRequestInspection;
completionCallback?: ((request: any, response: any) => void) | undefined;
/**
* Complete the prompt
Expand Down
22 changes: 22 additions & 0 deletions ts/packages/aiclient/src/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ import {
import { priorityQueue } from "async";
import registerDebug from "debug";
import { TokenCounter } from "./tokenCounter.js";
import { countTokens } from "gpt-tokenizer/encoding/o200k_base";
import { checkSerializedRequestConstraints } from "./requestConstraints.js";
import {
createOllamaChatModel,
OllamaApiSettings,
Expand Down Expand Up @@ -609,6 +611,26 @@ function createAzureOpenAIChatModel(
const model: ChatModelWithStreaming = {
completionSettings,
completionCallback,
inspectRequest(prompt, jsonSchema) {
const messages =
typeof prompt === "string"
? [{ role: "user" as const, content: prompt }]
: prompt;
const serializedRequest = JSON.stringify(
adapter.buildRequestBody(
makeModelRequest(messages, jsonSchema, false),
),
);
return {
serializedRequest,
estimatedInputTokens: countTokens(serializedRequest),
tokenizer: "o200k_base",
constraints: checkSerializedRequestConstraints(
serializedRequest,
settings.requestConstraints,
),
};
},
complete,
completeStream,
};
Expand Down
10 changes: 10 additions & 0 deletions ts/packages/aiclient/src/tokenCounter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { CompletionUsageStats } from "./apiTypes.js";
import registerDebug from "debug";
import { createRequire } from "node:module";

const debugTokens = registerDebug("typeagent:tokenCounter");

Expand Down Expand Up @@ -161,3 +162,12 @@ export class TokenCounter {
return this._counters;
}
}
const require = createRequire(import.meta.url);
let o200k: typeof import("gpt-tokenizer/encoding/o200k_base") | undefined;

export function countO200kTokens(text: string): number {
// Keep the experimental encoder off ordinary chat startup.
const tokenizer: typeof import("gpt-tokenizer/encoding/o200k_base") =
(o200k ??= require("gpt-tokenizer/encoding/o200k_base"));
return tokenizer.encode(text).length;
}
74 changes: 74 additions & 0 deletions ts/packages/aiclient/test/decisionModels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ describe("native decision models", () => {
acquisitions = 0;
});
afterEach(() => {
import.meta.jest.useRealTimers();
import.meta.jest.restoreAllMocks();
globalThis.fetch = originalFetch;
setActiveModelProvider(undefined);
});
Expand Down Expand Up @@ -563,6 +565,51 @@ describe("native decision models", () => {
if (!result.success) expect(result.httpStatus).toBe(status);
},
);
test.each([false, true])(
"Azure 429 respects Retry-After and bounds retries (exhaust=%s)",
async (exhaust) => {
const jest = import.meta.jest;
jest.useFakeTimers();
const jitter = jest
.spyOn(Math, "random")
.mockReturnValue(1 - Number.EPSILON);
let calls = 0;
try {
globalThis.fetch = async () => {
const reply =
++calls === 1 || exhaust
? new Response("{}", {
status: 429,
headers: { "Retry-After": "1" },
})
: response();
// Body stream I/O is outside Jest's virtual retry clock.
jest.spyOn(reply, "text").mockResolvedValue("{}");
jest.spyOn(reply, "json").mockResolvedValue(native);
return reply;
};
const pending = model(
config({ AZURE_OPENAI_MAX_RETRYATTEMPTS: "1" }),
).decide(request);
// Retry-After adds one second to the maximum five-second jitter.
await jest.advanceTimersByTimeAsync(5999);
expect(calls).toBe(1);
await jest.advanceTimersByTimeAsync(1);
const result = await pending;
expect(calls).toBe(2);
if (exhaust)
expect(result).toMatchObject({
success: false,
code: "rate_limit",
httpStatus: 429,
});
else expect(result.success).toBe(true);
} finally {
jitter.mockRestore();
jest.useRealTimers();
}
},
);
test("malformed JSON response is classified", async () => {
globalThis.fetch = async () => new Response("not-json");
expect(await model().decide(request)).toMatchObject({
Expand Down Expand Up @@ -775,6 +822,33 @@ describe("native decision models", () => {
});
expect(calls).toBe(0);
});
test.each(["azure", "copilot"] as const)(
"packing preflight enforces %s wire constraints without acquiring credentials",
(provider) => {
let calls = 0;
globalThis.fetch = async () => {
calls++;
return response();
};
const key =
provider === "azure"
? "azure:decision:decision_1"
: `copilot:decision:${model(config(), "copilot").model}`;
const nativeModel = model(
config({
TYPEAGENT_MODEL_REQUEST_CONSTRAINTS: JSON.stringify({
[key]: { outputTokens: 1 },
}),
}),
provider,
);
expect(() => nativeModel.checkRequest!(request)).toThrow(
"request constraints",
);
expect(calls).toBe(0);
expect(acquisitions).toBe(0);
},
);
test("configured Azure character guard rejects without truncating native input", async () => {
let calls = 0;
globalThis.fetch = async () => {
Expand Down
Loading
Loading