Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2050625
refactor(harness): 统一上下文 token 计账与媒体计费
dargoner Oct 14, 2026
b8dbe83
fix(harness): prune 计账仅累计入选块并将累计改为 long 避免回绕
dargoner Oct 14, 2026
54372a2
fix(harness): response format schema 纳入最终预算计费
dargoner Oct 14, 2026
167bc78
fix(harness): TokenEstimate 恢复七参兼容构造器
dargoner Oct 14, 2026
c03f2f3
fix(harness): 预算校验计入模型默认 response schema(Codex r3 M1)
dargoner Oct 14, 2026
0848f74
fix(extensions,harness): 补齐 Ollama/OpenAIResponses 默认 schema 预算入口(Cod…
dargoner Oct 14, 2026
2f357e8
fix(core,harness): 预算对齐供应商有效 format 语义(Codex r5)
dargoner Oct 14, 2026
081ce2b
fix(extensions,harness): 按 JSON 表示识别 Ollama 对象 schema 并保留映射字段(Codex r6)
dargoner Oct 14, 2026
9f4eb18
fix(harness): 输出预留纳入模型有效默认选项并统一压缩口径文档
dargoner Oct 14, 2026
d89caa7
test(harness): 补媒体触发路径与最终预算一致回归
dargoner Oct 14, 2026
ae79819
test(harness): 补四类 provider 计费平价与精确边界断言
dargoner Oct 14, 2026
7f46766
Merge branch 'main' into refactor/unify-context-token-accounting
xzxiaoshan Oct 16, 2026
1539fd9
Merge branch 'main' into refactor/unify-context-token-accounting
xzxiaoshan Oct 16, 2026
f983fb2
Merge remote-tracking branch 'origin/main' into refactor/unify-contex…
dargoner Oct 16, 2026
46a7860
Merge remote-tracking branch 'fork/refactor/unify-context-token-accou…
dargoner Oct 16, 2026
da163d1
Merge remote-tracking branch 'origin/main' into refactor/unify-contex…
dargoner Oct 18, 2026
f1ee22c
Merge remote-tracking branch 'origin/main' into refactor/unify-contex…
dargoner Oct 18, 2026
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
10 changes: 10 additions & 0 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -5028,6 +5028,16 @@ public boolean supportsNativeStructuredOutput() {
public int getContextWindowSize() {
return activeModel.get().getContextWindowSize();
}

@Override
public GenerateOptions getDefaultOptions() {
return activeModel.get().getDefaultOptions();
}

@Override
public GenerateOptions resolveOptions(GenerateOptions requestOptions) {
return activeModel.get().resolveOptions(requestOptions);
}
};
}

Expand Down
26 changes: 26 additions & 0 deletions agentscope-core/src/main/java/io/agentscope/core/model/Model.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,30 @@ default boolean supportsToolChoiceSpecific() {
default int getContextWindowSize() {
return 0;
}

/**
* Returns the generation options this model applies when a request does not override them, or
* {@code null} if unknown. Used by {@link #resolveOptions} as the provider-agnostic baseline.
*
* @return default generation options, or {@code null} when the model has none
*/
default GenerateOptions getDefaultOptions() {
return null;
}

/**
* Resolves the generation options a request will effectively be sent with, applying the
* model's defaults the same way the provider does before issuing the call. The harness-layer
* budget check consults this so provider-specific mappings (for example a native structured
* output field) cannot make the effective request diverge from what was accounted.
*
* @param requestOptions request-level options, possibly {@code null}
* @return effective options for the request
*/
default GenerateOptions resolveOptions(GenerateOptions requestOptions) {
GenerateOptions defaults = getDefaultOptions();
return defaults == null
? requestOptions
: GenerateOptions.mergeOptions(requestOptions, defaults);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ public int getContextWindowSize() {
return delegate.getContextWindowSize();
}

public GenerateOptions getDefaultOptions() {
return delegate.getDefaultOptions();
}

public GenerateOptions resolveOptions(GenerateOptions requestOptions) {
return delegate.resolveOptions(requestOptions);
}

public Flux<ChatResponse> stream(
List<Msg> messages, List<ToolSchema> tools, GenerateOptions options) {
return Flux.defer(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,11 @@ public String getModelName() {
return modelName;
}

@Override
public GenerateOptions getDefaultOptions() {
return defaultOptions;
}

/**
* Creates a builder for constructing AnthropicChatModel instances.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,11 @@ public String getModelName() {
return modelName;
}

@Override
public GenerateOptions getDefaultOptions() {
return defaultOptions;
}

@Override
public boolean supportsNativeStructuredOutput() {
if (Boolean.TRUE.equals(enableThinking)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,11 @@ public String getModelName() {
return modelName;
}

@Override
public GenerateOptions getDefaultOptions() {
return defaultOptions;
}

/**
* Close the Gemini client.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
*/
package io.agentscope.extensions.model.ollama;

import com.fasterxml.jackson.core.type.TypeReference;
import io.agentscope.core.formatter.Formatter;
import io.agentscope.core.formatter.JsonSchema;
import io.agentscope.core.formatter.ResponseFormat;
import io.agentscope.core.message.Msg;
import io.agentscope.core.model.ChatModelBase;
import io.agentscope.core.model.ChatResponse;
Expand All @@ -29,6 +32,8 @@
import io.agentscope.core.model.transport.HttpTransportFactory;
import io.agentscope.core.model.transport.OkHttpTransport;
import io.agentscope.core.model.transport.ProxyConfig;
import io.agentscope.core.util.JsonException;
import io.agentscope.core.util.JsonUtils;
import io.agentscope.extensions.model.ollama.dto.OllamaMessage;
import io.agentscope.extensions.model.ollama.dto.OllamaRequest;
import io.agentscope.extensions.model.ollama.dto.OllamaResponse;
Expand All @@ -37,6 +42,7 @@
import io.agentscope.extensions.model.ollama.options.OllamaOptions;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
Expand Down Expand Up @@ -135,6 +141,64 @@ public String getModelName() {
return this.modelName;
}

@Override
public GenerateOptions getDefaultOptions() {
return withSchemaProjection(defaultOptions.toGenerateOptions(), defaultOptions.getFormat());
}

@Override
public GenerateOptions resolveOptions(GenerateOptions requestOptions) {
OllamaOptions effective =
requestOptions == null ? defaultOptions : defaultOptions.merge(requestOptions);
return withSchemaProjection(effective.toGenerateOptions(), effective.getFormat());
}

/**
* Mirrors the send path's effective {@code format} semantics: the request only clears the
* default schema when it maps to a non-null format (json_schema, json_object, or a raw
* {@code format} body parameter), exactly as {@code fromGenerateOptions} and
* {@code additionalBodyParams} behave before the default merge. The converted options keep
* every mapped field; an object-shaped format is additionally projected as the response
* format the budget prices.
*/
private static GenerateOptions withSchemaProjection(GenerateOptions converted, Object format) {
ResponseFormat schemaFormat = objectSchemaFormat(format);
if (schemaFormat == null) {
return converted;
}
return GenerateOptions.mergeOptions(
GenerateOptions.builder().responseFormat(schemaFormat).build(), converted);
}

/**
* Recognizes a raw {@code format} whose JSON representation is an object, i.e. the schema the
* provider actually sends. Values like {@code "json"}, arrays or scalars carry no schema and
* are left to the send path untouched.
*/
private static ResponseFormat objectSchemaFormat(Object format) {
Map<String, Object> schema = toSchemaMap(format);
return schema == null
? null
: ResponseFormat.jsonSchema(JsonSchema.builder().schema(schema).build());
}

private static Map<String, Object> toSchemaMap(Object format) {
if (format instanceof Map<?, ?> schemaMap) {
@SuppressWarnings("unchecked")
Map<String, Object> schema = (Map<String, Object>) schemaMap;
return schema;
}
if (format == null) {
return null;
}
try {
return JsonUtils.getJsonCodec()
.convertValue(format, new TypeReference<Map<String, Object>>() {});
} catch (JsonException e) {
return null;
}
}

@Override
public boolean supportsToolChoiceSpecific() {
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ public String getModelName() {
return configuredOptions != null ? configuredOptions.getModelName() : null;
}

@Override
public GenerateOptions getDefaultOptions() {
return configuredOptions;
}

@Override
public boolean supportsToolChoiceSpecific() {
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ public String getModelName() {
return configuredOptions != null ? configuredOptions.getModelName() : null;
}

@Override
public GenerateOptions getDefaultOptions() {
return configuredOptions;
}

/** Package-private test seam for verifying model-level strict JSON schema configuration. */
Boolean getStrictJsonSchema() {
return strictJsonSchema;
Expand Down
37 changes: 37 additions & 0 deletions agentscope-harness/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,43 @@
<scope>test</scope>
</dependency>

<!-- Price provider-level default response schemas through the real adapter classes. -->
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-model-ollama</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-model-openai-official</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-model-openai</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-model-dashscope</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-model-anthropic</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-model-gemini</artifactId>
<scope>test</scope>
</dependency>

<!-- YAML parsing for subagent config -->
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,8 @@
*/
package io.agentscope.harness.agent.context;

import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ThinkingBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.middleware.ModelCallInput;
import io.agentscope.core.util.JsonUtils;
import io.agentscope.core.model.GenerateOptions;

/** Replace with an adapter-specific tokenizer when available. Estimates are not provider usage. */
@FunctionalInterface
Expand All @@ -36,39 +31,19 @@ record Estimate(long tokens, String method, boolean exact) {
}

static ContextTokenEstimator approximate() {
TokenAccountingService service = TokenAccountingService.defaults();
return input -> {
long count = 0;
for (var msg : input.messages()) {
count += 12 + text(msg.getName());
for (var block : msg.getContent()) count += block(block);
}
for (var tool : input.tools()) {
count += 16 + text(JsonUtils.getJsonCodec().toJson(tool));
}
return new Estimate(count, "mixed-text-2.5chars-media-2048-v1", false);
GenerateOptions options =
input.model() == null
? input.options()
: input.model().resolveOptions(input.options());
var estimate =
service.estimate(
new TokenAccountingService.TokenSurface(
input.messages(),
input.tools(),
options == null ? null : options.getResponseFormat()));
return new Estimate(estimate.totalTokens(), estimate.method(), estimate.exact());
};
}

private static long text(String value) {
return value == null ? 0 : (long) Math.ceil(value.length() / 2.5);
}

private static long block(ContentBlock block) {
if (block instanceof TextBlock text) return text(text.getText());
if (block instanceof ThinkingBlock thinking) return text(thinking.getThinking());
if (block instanceof ToolUseBlock use) {
return 12
+ text(use.getId())
+ text(use.getName())
+ text(JsonUtils.getJsonCodec().toJson(use.getInput()))
+ text(use.getContent());
}
if (block instanceof ToolResultBlock result) {
long tokens = 12 + text(result.getId()) + text(result.getName());
for (var output : result.getOutput()) tokens += block(output);
return tokens;
}
// Explicitly approximate: media cost depends on provider, resolution and duration.
return 2048;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import io.agentscope.core.middleware.ModelRequestPreparer;
import io.agentscope.core.middleware.ReasoningInput;
import io.agentscope.core.middleware.TaskContextProjection;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.state.AgentState;
import io.agentscope.core.state.TaskContextState;
import io.agentscope.core.util.JsonUtils;
Expand Down Expand Up @@ -548,11 +549,15 @@ private long inputLimit(ModelCallInput input) {
long window = input.model().getContextWindowSize();
long output = policy.reservedOutputTokens();
if (output == 0) output = window > 0 ? Math.min(4096, Math.max(1, window / 8)) : 4096;
if (input.options() != null) {
if (input.options().getMaxTokens() != null)
output = Math.max(output, input.options().getMaxTokens());
if (input.options().getMaxCompletionTokens() != null)
output = Math.max(output, input.options().getMaxCompletionTokens());
// The provider applies the model's default options to the outgoing request, so the
// output reservation must be read from the same effective options: a default
// maxTokens that never appears in request options still consumes budget.
GenerateOptions effective = input.model().resolveOptions(input.options());
if (effective != null) {
if (effective.getMaxTokens() != null)
output = Math.max(output, effective.getMaxTokens());
if (effective.getMaxCompletionTokens() != null)
output = Math.max(output, effective.getMaxCompletionTokens());
}
long safety =
policy.safetyMarginTokens() > 0
Expand Down
Loading
Loading