feat: Implement tokenizer resolver and fallback tokenizer; update model profiles and README
This commit is contained in:
25
README.md
25
README.md
@@ -105,12 +105,6 @@ If you disable a module that another module depends on, you must provide the mis
|
|||||||
| `lmlib.client.retry.backoff-interval-min` | Long | `125` | Minimum retry backoff in milliseconds. |
|
| `lmlib.client.retry.backoff-interval-min` | Long | `125` | Minimum retry backoff in milliseconds. |
|
||||||
| `lmlib.client.retry.backoff-interval-max` | Long | `2500` | Maximum retry backoff in milliseconds. |
|
| `lmlib.client.retry.backoff-interval-max` | Long | `2500` | Maximum retry backoff in milliseconds. |
|
||||||
|
|
||||||
### Tokenizer Configuration
|
|
||||||
|
|
||||||
| Property | Type | Default | Description |
|
|
||||||
|----------|------|---------|-------------|
|
|
||||||
| `tokenizer.strategy` | String | `jTokkit` | Token counting strategy. Supported values are `jTokkit` and `llamaServer`. |
|
|
||||||
|
|
||||||
## Optional Dependencies
|
## Optional Dependencies
|
||||||
|
|
||||||
### Caching Support
|
### Caching Support
|
||||||
@@ -148,7 +142,19 @@ For the recommended `jTokkit` tokenizer:
|
|||||||
</dependency>
|
</dependency>
|
||||||
```
|
```
|
||||||
|
|
||||||
For `llamaServer`, no extra dependency is required because tokenization is delegated to the remote server.
|
For `llamaServer`, no extra dependency is required because tokenization is delegated to the remote server through the configured model endpoint.
|
||||||
|
|
||||||
|
Available built-in tokenizer names:
|
||||||
|
|
||||||
|
- `jTokkit`
|
||||||
|
- `llamaServer`
|
||||||
|
- `fallback`
|
||||||
|
|
||||||
|
### Tokenizer Configuration
|
||||||
|
|
||||||
|
lm-lib registers tokenizer implementations as beans when their dependencies are available, then resolves the tokenizer at runtime from the active `ModelProfile`. Applications can implement the `Tokenizer` interface to inject their own Tokenizers.
|
||||||
|
|
||||||
|
Tokenizer selection is configured per model profile with the `tokenizer` property. If a profile does not specify a tokenizer, lm-lib prefers `jTokkit` when the dependency is present and otherwise falls back to the built-in `fallback` tokenizer. The fallback tokenizer is deterministic and reversible, but it only provides approximate token counts.
|
||||||
|
|
||||||
## Model Profiles
|
## Model Profiles
|
||||||
|
|
||||||
@@ -194,6 +200,7 @@ Create a `ModelProfile` directly when you want full runtime control and do not w
|
|||||||
```java
|
```java
|
||||||
final var modelProfile = new ModelProfile(
|
final var modelProfile = new ModelProfile(
|
||||||
"", // prompts
|
"", // prompts
|
||||||
|
"jTokkit", // tokenizer
|
||||||
"cl100k_base", // tokenEncoding
|
"cl100k_base", // tokenEncoding
|
||||||
2000, // maxTokens
|
2000, // maxTokens
|
||||||
32768, // maxContextLength
|
32768, // maxContextLength
|
||||||
@@ -241,6 +248,7 @@ Each profile file is a `.properties` resource on the classpath.
|
|||||||
|----------|------|----------|-------------|---------|
|
|----------|------|----------|-------------|---------|
|
||||||
| `category` | String | No | Short profile label used in logging and graph traces. If omitted in the file, it is inferred from the `ModelCategory` used to load it. | `M`, `L`, `CUSTOM` |
|
| `category` | String | No | Short profile label used in logging and graph traces. If omitted in the file, it is inferred from the `ModelCategory` used to load it. | `M`, `L`, `CUSTOM` |
|
||||||
| `prompts` | String | Yes | Prompt strategy name for text completions. Use an empty value for chat completions. | `llama4`, `gpt-oss`, `no`, `` |
|
| `prompts` | String | Yes | Prompt strategy name for text completions. Use an empty value for chat completions. | `llama4`, `gpt-oss`, `no`, `` |
|
||||||
|
| `tokenizer` | String | No | Tokenizer name used for counting and prompt trimming. Defaults to `jTokkit` when available, otherwise `fallback`. | `jTokkit`, `llamaServer`, `fallback` |
|
||||||
| `tokenEncoding` | String | Yes | Token encoding scheme used for token counting. | `cl100k_base`, `none` |
|
| `tokenEncoding` | String | Yes | Token encoding scheme used for token counting. | `cl100k_base`, `none` |
|
||||||
| `maxTokens` | Integer | Yes | Maximum response tokens. | `1500`, `2500` |
|
| `maxTokens` | Integer | Yes | Maximum response tokens. | `1500`, `2500` |
|
||||||
| `maxContextLength` | Integer | Yes | Maximum total context length. | `16000`, `32768`, `128000` |
|
| `maxContextLength` | Integer | Yes | Maximum total context length. | `16000`, `32768`, `128000` |
|
||||||
@@ -265,6 +273,7 @@ Notes:
|
|||||||
- `cost1MCachedInTokens` defaults to the normal input token cost when not provided.
|
- `cost1MCachedInTokens` defaults to the normal input token cost when not provided.
|
||||||
- `cost1MReasoningOutTokens` defaults to `0` when not provided.
|
- `cost1MReasoningOutTokens` defaults to `0` when not provided.
|
||||||
- Reasoning properties only apply to models that expose separate reasoning output.
|
- Reasoning properties only apply to models that expose separate reasoning output.
|
||||||
|
- `tokenEncoding` is primarily relevant for tokenizer implementations that support multiple encodings, such as `jTokkit`.
|
||||||
|
|
||||||
### Example Profile Files
|
### Example Profile Files
|
||||||
|
|
||||||
@@ -273,6 +282,7 @@ Local chat completion:
|
|||||||
```properties
|
```properties
|
||||||
category=M
|
category=M
|
||||||
prompts=
|
prompts=
|
||||||
|
tokenizer=llamaServer
|
||||||
tokenEncoding=cl100k_base
|
tokenEncoding=cl100k_base
|
||||||
maxTokens=2500
|
maxTokens=2500
|
||||||
maxContextLength=16000
|
maxContextLength=16000
|
||||||
@@ -293,6 +303,7 @@ Local text completion:
|
|||||||
```properties
|
```properties
|
||||||
category=S
|
category=S
|
||||||
prompts=gpt-oss
|
prompts=gpt-oss
|
||||||
|
tokenizer=llamaServer
|
||||||
tokenEncoding=cl100k_base
|
tokenEncoding=cl100k_base
|
||||||
maxTokens=2500
|
maxTokens=2500
|
||||||
maxContextLength=16000
|
maxContextLength=16000
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import de.extio.lmlib.profile.ModelCategory;
|
|||||||
import de.extio.lmlib.profile.ModelProfile;
|
import de.extio.lmlib.profile.ModelProfile;
|
||||||
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
|
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
|
||||||
import de.extio.lmlib.profile.ModelProfileService;
|
import de.extio.lmlib.profile.ModelProfileService;
|
||||||
import de.extio.lmlib.token.Tokenizer;
|
import de.extio.lmlib.token.TokenizerResolver;
|
||||||
|
|
||||||
public abstract class AbstractCompletionClient implements Client, DisposableBean {
|
public abstract class AbstractCompletionClient implements Client, DisposableBean {
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ public abstract class AbstractCompletionClient implements Client, DisposableBean
|
|||||||
protected RestClient.Builder restClientBuilder;
|
protected RestClient.Builder restClientBuilder;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
protected Tokenizer tokenizer;
|
protected TokenizerResolver tokenizerResolver;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
protected ModelProfileService modelProfileService;
|
protected ModelProfileService modelProfileService;
|
||||||
@@ -214,14 +214,14 @@ public abstract class AbstractCompletionClient implements Client, DisposableBean
|
|||||||
}
|
}
|
||||||
// Fallback to tokenizer estimation
|
// Fallback to tokenizer estimation
|
||||||
if (inTokens <= 0 && prompt != null && !prompt.isBlank()) {
|
if (inTokens <= 0 && prompt != null && !prompt.isBlank()) {
|
||||||
inTokens = this.tokenizer.count(prompt, modelProfile);
|
inTokens = this.tokenizerResolver.count(prompt, modelProfile);
|
||||||
}
|
}
|
||||||
if (reasoningOutTokens <= 0 && reasoning != null && !reasoning.isBlank()) {
|
if (reasoningOutTokens <= 0 && reasoning != null && !reasoning.isBlank()) {
|
||||||
reasoningOutTokens = this.tokenizer.count(reasoning, modelProfile);
|
reasoningOutTokens = this.tokenizerResolver.count(reasoning, modelProfile);
|
||||||
outTokens -= reasoningOutTokens;
|
outTokens -= reasoningOutTokens;
|
||||||
}
|
}
|
||||||
if (outTokens <= 0 && response != null && !response.isBlank()) {
|
if (outTokens <= 0 && response != null && !response.isBlank()) {
|
||||||
outTokens = this.tokenizer.count(response, modelProfile);
|
outTokens = this.tokenizerResolver.count(response, modelProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate cost - inTokens and outTokens are already exclusive of cached/reasoning tokens
|
// Calculate cost - inTokens and outTokens are already exclusive of cached/reasoning tokens
|
||||||
|
|||||||
@@ -308,13 +308,13 @@ public class ChatCompletionClient extends AbstractCompletionClient {
|
|||||||
var prompt = turn.text() != null ? turn.text() : "";
|
var prompt = turn.text() != null ? turn.text() : "";
|
||||||
var trimmed = false;
|
var trimmed = false;
|
||||||
|
|
||||||
final var textTokens = this.tokenizer.count(prompt, modelProfile);
|
final var textTokens = this.tokenizerResolver.count(prompt, modelProfile);
|
||||||
final int maxLength = modelProfile.maxContextLength() - modelProfile.maxTokens() - 15; // 15 is a margin for EOT and other special tokens
|
final int maxLength = modelProfile.maxContextLength() - modelProfile.maxTokens() - 15; // 15 is a margin for EOT and other special tokens
|
||||||
if (tokens.addAndGet(textTokens) > maxLength) {
|
if (tokens.addAndGet(textTokens) > maxLength) {
|
||||||
var tokenized = this.tokenizer.tokenize(prompt, modelProfile);
|
var tokenized = this.tokenizerResolver.tokenize(prompt, modelProfile);
|
||||||
LOGGER.warn("Prompt too long: {} tokens / {} max. Prompt will be cut!", tokenized.size(), maxLength);
|
LOGGER.warn("Prompt too long: {} tokens / {} max. Prompt will be cut!", tokenized.size(), maxLength);
|
||||||
tokenized = tokenized.subList(0, tokenized.size() - (tokens.get() - maxLength));
|
tokenized = tokenized.subList(0, tokenized.size() - (tokens.get() - maxLength));
|
||||||
prompt = this.tokenizer.detokenize(tokenized, modelProfile);
|
prompt = this.tokenizerResolver.detokenize(tokenized, modelProfile);
|
||||||
trimmed = true;
|
trimmed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,12 +200,12 @@ public class TextCompletionClient extends AbstractCompletionClient {
|
|||||||
private String createPrompt(final Conversation conversation, final ModelProfile modelProfile, final PromptStrategy promptStrategy) {
|
private String createPrompt(final Conversation conversation, final ModelProfile modelProfile, final PromptStrategy promptStrategy) {
|
||||||
StringBuilder prompt = this.formatConversation(conversation, promptStrategy);
|
StringBuilder prompt = this.formatConversation(conversation, promptStrategy);
|
||||||
|
|
||||||
List<Long> tokenized = this.tokenizer.tokenize(prompt.toString(), modelProfile);
|
List<Long> tokenized = this.tokenizerResolver.tokenize(prompt.toString(), modelProfile);
|
||||||
final int maxLength = modelProfile.maxContextLength() - modelProfile.maxTokens() - 15; // 15 is a margin for EOT and other special tokens
|
final int maxLength = modelProfile.maxContextLength() - modelProfile.maxTokens() - 15; // 15 is a margin for EOT and other special tokens
|
||||||
if (tokenized.size() > maxLength) {
|
if (tokenized.size() > maxLength) {
|
||||||
LOGGER.warn("Prompt too long: {} tokens / {} max. Prompt will be cut!", tokenized.size(), maxLength);
|
LOGGER.warn("Prompt too long: {} tokens / {} max. Prompt will be cut!", tokenized.size(), maxLength);
|
||||||
tokenized = tokenized.subList(0, maxLength);
|
tokenized = tokenized.subList(0, maxLength);
|
||||||
prompt = new StringBuilder(this.tokenizer.detokenize(tokenized, modelProfile));
|
prompt = new StringBuilder(this.tokenizerResolver.detokenize(tokenized, modelProfile));
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGGER.debug("Completion request for {}. Input tokens: {}", modelProfile.category(), tokenized.size());
|
LOGGER.debug("Completion request for {}. Input tokens: {}", modelProfile.category(), tokenized.size());
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import java.math.BigDecimal;
|
|||||||
|
|
||||||
public record ModelProfile(
|
public record ModelProfile(
|
||||||
String prompt,
|
String prompt,
|
||||||
|
String tokenizer,
|
||||||
String tokenEncoding,
|
String tokenEncoding,
|
||||||
int maxTokens,
|
int maxTokens,
|
||||||
int maxContextLength,
|
int maxContextLength,
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public class ModelProfileService {
|
|||||||
final var promptTemplate = Optional.ofNullable(resource.getProperty("prompts"))
|
final var promptTemplate = Optional.ofNullable(resource.getProperty("prompts"))
|
||||||
.map(Object::toString)
|
.map(Object::toString)
|
||||||
.orElse("");
|
.orElse("");
|
||||||
|
final var tokenizer = Optional.ofNullable(resource.getProperty("tokenizer"))
|
||||||
|
.map(Object::toString)
|
||||||
|
.orElse(null);
|
||||||
final var tokenEncoding = Optional.ofNullable(resource.getProperty("tokenEncoding"))
|
final var tokenEncoding = Optional.ofNullable(resource.getProperty("tokenEncoding"))
|
||||||
.map(Object::toString)
|
.map(Object::toString)
|
||||||
.orElse("none");
|
.orElse("none");
|
||||||
@@ -109,7 +112,7 @@ public class ModelProfileService {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ModelProfile(promptTemplate, tokenEncoding, maxTokens, maxContextLength, temperature, topP, modelProvider, modelNameCfg, url, apiKey, costPerInToken, costPerCachedInToken, costPerOutToken, costPerReasoningOutToken, reasoningEffort, reasoningSummaryDetails, category);
|
return new ModelProfile(promptTemplate, tokenizer, tokenEncoding, maxTokens, maxContextLength, temperature, topP, modelProvider, modelNameCfg, url, apiKey, costPerInToken, costPerCachedInToken, costPerOutToken, costPerReasoningOutToken, reasoningEffort, reasoningSummaryDetails, category);
|
||||||
}
|
}
|
||||||
catch (final IOException e) {
|
catch (final IOException e) {
|
||||||
LOGGER.error("Error while reading model profile", e);
|
LOGGER.error("Error while reading model profile", e);
|
||||||
|
|||||||
169
src/main/java/de/extio/lmlib/token/FallbackTokenizer.java
Normal file
169
src/main/java/de/extio/lmlib/token/FallbackTokenizer.java
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package de.extio.lmlib.token;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import de.extio.lmlib.profile.ModelProfile;
|
||||||
|
|
||||||
|
final class FallbackTokenizer implements Tokenizer {
|
||||||
|
|
||||||
|
static final String NAME = "fallback";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Long> tokenize(final String txt, final ModelProfile modelProfile) {
|
||||||
|
if (txt == null || txt.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
final var tokens = new ArrayList<Long>();
|
||||||
|
var index = 0;
|
||||||
|
while (index < txt.length()) {
|
||||||
|
final var codePoint = txt.codePointAt(index);
|
||||||
|
final var charCount = Character.charCount(codePoint);
|
||||||
|
if (Character.isWhitespace(codePoint)) {
|
||||||
|
final var end = this.consumeWhitespace(txt, index);
|
||||||
|
tokens.addAll(this.encodeChunked(txt.substring(index, end), 6));
|
||||||
|
index = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Character.isLetterOrDigit(codePoint)) {
|
||||||
|
final var end = this.consumeAlphaNumeric(txt, index);
|
||||||
|
tokens.addAll(this.encodeWordLike(txt.substring(index, end)));
|
||||||
|
index = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tokens.add(this.encodeToken(txt.substring(index, index + charCount)));
|
||||||
|
index += charCount;
|
||||||
|
}
|
||||||
|
return List.copyOf(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int count(final String txt, final ModelProfile modelProfile) {
|
||||||
|
return this.tokenize(txt, modelProfile).size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String detokenize(final List<Long> tokens, final ModelProfile modelProfile) {
|
||||||
|
final var builder = new StringBuilder();
|
||||||
|
for (final var token : tokens) {
|
||||||
|
builder.append(this.decodeToken(token));
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int consumeWhitespace(final String txt, final int start) {
|
||||||
|
var index = start;
|
||||||
|
while (index < txt.length()) {
|
||||||
|
final var codePoint = txt.codePointAt(index);
|
||||||
|
if (!Character.isWhitespace(codePoint)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
index += Character.charCount(codePoint);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int consumeAlphaNumeric(final String txt, final int start) {
|
||||||
|
var index = start;
|
||||||
|
while (index < txt.length()) {
|
||||||
|
final var codePoint = txt.codePointAt(index);
|
||||||
|
if (!Character.isLetterOrDigit(codePoint)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
index += Character.charCount(codePoint);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> encodeWordLike(final String text) {
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (this.isAsciiDigits(text)) {
|
||||||
|
return this.encodeChunked(text, 3);
|
||||||
|
}
|
||||||
|
if (this.isAsciiLetters(text)) {
|
||||||
|
return this.encodeChunked(text, 4);
|
||||||
|
}
|
||||||
|
return this.encodeChunkedByUtf8Bytes(text, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAsciiDigits(final String text) {
|
||||||
|
return text.chars().allMatch(ch -> ch >= '0' && ch <= '9');
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAsciiLetters(final String text) {
|
||||||
|
return text.chars().allMatch(ch -> ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z');
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> encodeChunked(final String text, final int maxCharsPerToken) {
|
||||||
|
final var tokens = new ArrayList<Long>();
|
||||||
|
for (var index = 0; index < text.length();) {
|
||||||
|
var end = index;
|
||||||
|
var charCount = 0;
|
||||||
|
while (end < text.length() && charCount < maxCharsPerToken) {
|
||||||
|
final var codePoint = text.codePointAt(end);
|
||||||
|
end += Character.charCount(codePoint);
|
||||||
|
charCount++;
|
||||||
|
}
|
||||||
|
tokens.add(this.encodeToken(text.substring(index, end)));
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> encodeChunkedByUtf8Bytes(final String text, final int maxBytesPerToken) {
|
||||||
|
final var tokens = new ArrayList<Long>();
|
||||||
|
for (var index = 0; index < text.length();) {
|
||||||
|
var end = index;
|
||||||
|
var bytes = 0;
|
||||||
|
while (end < text.length()) {
|
||||||
|
final var codePoint = text.codePointAt(end);
|
||||||
|
final var candidateEnd = end + Character.charCount(codePoint);
|
||||||
|
final var candidateBytes = text.substring(end, candidateEnd).getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
if (bytes > 0 && bytes + candidateBytes > maxBytesPerToken) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
end = candidateEnd;
|
||||||
|
bytes += candidateBytes;
|
||||||
|
if (bytes >= maxBytesPerToken) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens.add(this.encodeToken(text.substring(index, end)));
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long encodeToken(final String tokenText) {
|
||||||
|
final var bytes = tokenText.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (bytes.length == 0 || bytes.length > 7) {
|
||||||
|
throw new IllegalArgumentException("Fallback tokenizer token length must be between 1 and 7 bytes");
|
||||||
|
}
|
||||||
|
long value = bytes.length;
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
value |= (bytes[i] & 0xffL) << ((i + 1) * 8);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String decodeToken(final long token) {
|
||||||
|
final var length = (int) (token & 0xffL);
|
||||||
|
if (length <= 0 || length > 7) {
|
||||||
|
throw new IllegalArgumentException("Invalid fallback tokenizer token length: " + length);
|
||||||
|
}
|
||||||
|
final var bytes = new byte[length];
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
bytes[i] = (byte) ((token >> ((i + 1) * 8)) & 0xffL);
|
||||||
|
}
|
||||||
|
return new String(bytes, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import de.extio.lmlib.profile.ModelProfile;
|
|||||||
|
|
||||||
final class JTokkitTokenizer implements Tokenizer {
|
final class JTokkitTokenizer implements Tokenizer {
|
||||||
|
|
||||||
|
static final String NAME = "jTokkit";
|
||||||
|
|
||||||
private static final EncodingRegistry registry;
|
private static final EncodingRegistry registry;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
@@ -26,10 +28,15 @@ final class JTokkitTokenizer implements Tokenizer {
|
|||||||
return registry.getEncoding(EncodingType.fromName(k).orElseThrow(() -> new IllegalArgumentException("Token encoding " + k)));
|
return registry.getEncoding(EncodingType.fromName(k).orElseThrow(() -> new IllegalArgumentException("Token encoding " + k)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return NAME;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Long> tokenize(final String txt, final ModelProfile modelProfile) {
|
public List<Long> tokenize(final String txt, final ModelProfile modelProfile) {
|
||||||
return this.encodings
|
return this.encodings
|
||||||
.get(modelProfile.tokenEncoding())
|
.get(this.getTokenEncoding(modelProfile))
|
||||||
.encodeOrdinary(txt)
|
.encodeOrdinary(txt)
|
||||||
.boxed()
|
.boxed()
|
||||||
.stream()
|
.stream()
|
||||||
@@ -39,7 +46,7 @@ final class JTokkitTokenizer implements Tokenizer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int count(final String txt, final ModelProfile modelProfile) {
|
public int count(final String txt, final ModelProfile modelProfile) {
|
||||||
return this.encodings.get(modelProfile.tokenEncoding()).countTokensOrdinary(txt);
|
return this.encodings.get(this.getTokenEncoding(modelProfile)).countTokensOrdinary(txt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -47,7 +54,14 @@ final class JTokkitTokenizer implements Tokenizer {
|
|||||||
final var ial = new IntArrayList(tokens.size());
|
final var ial = new IntArrayList(tokens.size());
|
||||||
tokens.forEach(t -> ial.add(t.intValue()));
|
tokens.forEach(t -> ial.add(t.intValue()));
|
||||||
return this.encodings
|
return this.encodings
|
||||||
.get(modelProfile.tokenEncoding())
|
.get(this.getTokenEncoding(modelProfile))
|
||||||
.decode(ial);
|
.decode(ial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getTokenEncoding(final ModelProfile modelProfile) {
|
||||||
|
if (modelProfile.tokenEncoding() == null || modelProfile.tokenEncoding().isBlank() || "none".equalsIgnoreCase(modelProfile.tokenEncoding())) {
|
||||||
|
return "cl100k_base"; // default encoding, used by most models. Will be used if the profile doesn't specify an encoding or specifies an invalid one.
|
||||||
|
}
|
||||||
|
return modelProfile.tokenEncoding();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.extio.lmlib.token;
|
package de.extio.lmlib.token;
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -12,6 +11,8 @@ import de.extio.lmlib.profile.ModelProfile;
|
|||||||
|
|
||||||
final class LlamaServerTokenizer implements Tokenizer {
|
final class LlamaServerTokenizer implements Tokenizer {
|
||||||
|
|
||||||
|
static final String NAME = "llamaServer";
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(LlamaServerTokenizer.class);
|
private static final Logger LOGGER = LoggerFactory.getLogger(LlamaServerTokenizer.class);
|
||||||
|
|
||||||
private final RestClient.Builder restClientBuilder;
|
private final RestClient.Builder restClientBuilder;
|
||||||
@@ -20,6 +21,11 @@ final class LlamaServerTokenizer implements Tokenizer {
|
|||||||
this.restClientBuilder = restClientBuilder;
|
this.restClientBuilder = restClientBuilder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return NAME;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Long> tokenize(final String txt, final ModelProfile modelProfile) {
|
public List<Long> tokenize(final String txt, final ModelProfile modelProfile) {
|
||||||
LOGGER.debug("Requesting tokenizing");
|
LOGGER.debug("Requesting tokenizing");
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import de.extio.lmlib.profile.ModelProfile;
|
|||||||
|
|
||||||
public interface Tokenizer {
|
public interface Tokenizer {
|
||||||
|
|
||||||
|
String getName();
|
||||||
|
|
||||||
List<Long> tokenize(String txt, ModelProfile modelProfile);
|
List<Long> tokenize(String txt, ModelProfile modelProfile);
|
||||||
|
|
||||||
int count(String txt, ModelProfile modelProfile);
|
int count(String txt, ModelProfile modelProfile);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.extio.lmlib.token;
|
|||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
@@ -13,18 +14,29 @@ import org.springframework.web.client.RestClient;
|
|||||||
public class TokenizerAutoConfiguration {
|
public class TokenizerAutoConfiguration {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnProperty(name = "tokenizer.strategy", havingValue = "llamaServer")
|
@ConditionalOnBean(name = "lmLibRestClientBuilder")
|
||||||
@ConditionalOnMissingBean(type = "de.extio.lmlib.token.Tokenizer")
|
@ConditionalOnMissingBean(LlamaServerTokenizer.class)
|
||||||
Tokenizer llamaServerTokenizer(@Qualifier("lmLibRestClientBuilder") final RestClient.Builder restClientBuilder) {
|
Tokenizer llamaServerTokenizer(@Qualifier("lmLibRestClientBuilder") final RestClient.Builder restClientBuilder) {
|
||||||
return new LlamaServerTokenizer(restClientBuilder);
|
return new LlamaServerTokenizer(restClientBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnClass(name = "com.knuddels.jtokkit.Encodings")
|
@ConditionalOnClass(name = "com.knuddels.jtokkit.Encodings")
|
||||||
@ConditionalOnProperty(name = "tokenizer.strategy", havingValue = "jTokkit", matchIfMissing = true)
|
@ConditionalOnMissingBean(JTokkitTokenizer.class)
|
||||||
@ConditionalOnMissingBean(type = "de.extio.lmlib.token.Tokenizer")
|
|
||||||
Tokenizer jTokkitTokenizer() {
|
Tokenizer jTokkitTokenizer() {
|
||||||
return new JTokkitTokenizer();
|
return new JTokkitTokenizer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean(FallbackTokenizer.class)
|
||||||
|
Tokenizer fallbackTokenizer() {
|
||||||
|
return new FallbackTokenizer();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean
|
||||||
|
TokenizerResolver tokenizerResolver(final java.util.List<Tokenizer> tokenizers) {
|
||||||
|
return new TokenizerResolver(tokenizers);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/main/java/de/extio/lmlib/token/TokenizerResolver.java
Normal file
55
src/main/java/de/extio/lmlib/token/TokenizerResolver.java
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package de.extio.lmlib.token;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import de.extio.lmlib.profile.ModelProfile;
|
||||||
|
|
||||||
|
public class TokenizerResolver {
|
||||||
|
|
||||||
|
private static final List<String> DEFAULT_TOKENIZER_ORDER = List.of(JTokkitTokenizer.NAME, FallbackTokenizer.NAME);
|
||||||
|
|
||||||
|
private final Map<String, Tokenizer> tokenizersByName;
|
||||||
|
|
||||||
|
public TokenizerResolver(final List<Tokenizer> tokenizers) {
|
||||||
|
final var tokenizersByName = new LinkedHashMap<String, Tokenizer>();
|
||||||
|
for (final var tokenizer : tokenizers) {
|
||||||
|
final var previous = tokenizersByName.put(tokenizer.getName(), tokenizer);
|
||||||
|
if (previous != null) {
|
||||||
|
throw new IllegalStateException("Duplicate tokenizer name: " + tokenizer.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.tokenizersByName = Map.copyOf(tokenizersByName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Tokenizer resolve(final ModelProfile modelProfile) {
|
||||||
|
if (modelProfile != null && modelProfile.tokenizer() != null && !modelProfile.tokenizer().isBlank()) {
|
||||||
|
final var tokenizer = this.tokenizersByName.get(modelProfile.tokenizer());
|
||||||
|
if (tokenizer == null) {
|
||||||
|
throw new IllegalStateException("No tokenizer configured with name: " + modelProfile.tokenizer());
|
||||||
|
}
|
||||||
|
return tokenizer;
|
||||||
|
}
|
||||||
|
for (final var tokenizerName : DEFAULT_TOKENIZER_ORDER) {
|
||||||
|
final var tokenizer = this.tokenizersByName.get(tokenizerName);
|
||||||
|
if (tokenizer != null) {
|
||||||
|
return tokenizer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("No tokenizer available");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Long> tokenize(final String txt, final ModelProfile modelProfile) {
|
||||||
|
return this.resolve(modelProfile).tokenize(txt, modelProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int count(final String txt, final ModelProfile modelProfile) {
|
||||||
|
return this.resolve(modelProfile).count(txt, modelProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String detokenize(final List<Long> tokens, final ModelProfile modelProfile) {
|
||||||
|
return this.resolve(modelProfile).detokenize(tokens, modelProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package de.extio.lmlib.token;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.extio.lmlib.profile.ModelProfile;
|
||||||
|
import de.extio.lmlib.profile.ModelProfile.ModelProvider;
|
||||||
|
|
||||||
|
public class TokenizerResolverTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesTokenizerFromProfileName() {
|
||||||
|
final var fallbackTokenizer = new FallbackTokenizer();
|
||||||
|
final var customTokenizer = new NamedTokenizer("custom");
|
||||||
|
final var resolver = new TokenizerResolver(List.of(fallbackTokenizer, customTokenizer));
|
||||||
|
|
||||||
|
assertSame(customTokenizer, resolver.resolve(this.createProfile("custom")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prefersJTokkitAsDefaultWhenAvailable() {
|
||||||
|
final var fallbackTokenizer = new FallbackTokenizer();
|
||||||
|
final var jTokkitTokenizer = new NamedTokenizer(JTokkitTokenizer.NAME);
|
||||||
|
final var resolver = new TokenizerResolver(List.of(fallbackTokenizer, jTokkitTokenizer));
|
||||||
|
|
||||||
|
assertSame(jTokkitTokenizer, resolver.resolve(this.createProfile(null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fallsBackWhenNoPreferredTokenizerExists() {
|
||||||
|
final var fallbackTokenizer = new FallbackTokenizer();
|
||||||
|
final var resolver = new TokenizerResolver(List.of(fallbackTokenizer));
|
||||||
|
|
||||||
|
assertSame(fallbackTokenizer, resolver.resolve(this.createProfile(null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ModelProfile createProfile(final String tokenizer) {
|
||||||
|
return new ModelProfile(
|
||||||
|
"",
|
||||||
|
tokenizer,
|
||||||
|
"cl100k_base",
|
||||||
|
1024,
|
||||||
|
4096,
|
||||||
|
0.4,
|
||||||
|
1.0,
|
||||||
|
ModelProvider.OAI_CHAT_COMPLETION,
|
||||||
|
"test-model",
|
||||||
|
"http://localhost:5001",
|
||||||
|
"",
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"TEST");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NamedTokenizer implements Tokenizer {
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
private NamedTokenizer(final String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Long> tokenize(final String txt, final ModelProfile modelProfile) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int count(final String txt, final ModelProfile modelProfile) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String detokenize(final List<Long> tokens, final ModelProfile modelProfile) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package de.extio.lmlib.token;
|
package de.extio.lmlib.token;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Disabled;
|
import org.junit.jupiter.api.Disabled;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -42,6 +45,29 @@ public class TokenizerTest {
|
|||||||
assertEquals(TEXT, tokenizer.detokenize(tokens, profile));
|
assertEquals(TEXT, tokenizer.detokenize(tokens, profile));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fallback() {
|
||||||
|
final var tokenizer = new FallbackTokenizer();
|
||||||
|
final var profile = this.modelProfileService.getModelProfile(ModelCategory.MEDIUM.getModelProfile());
|
||||||
|
|
||||||
|
assertEquals(47, tokenizer.count(TEXT, profile));
|
||||||
|
final var tokens = tokenizer.tokenize(TEXT, profile);
|
||||||
|
assertEquals(47, tokens.size());
|
||||||
|
assertEquals("[465675044868, 28417, 8193, 495689692164, 8193, 7563522, 8193, 24833, 8193, 500236121092, 8449, 8193, 2003781635, 8193, 1701994755, 8193, 1970239747, 8193, 474215310340, 26369, 16129, 8193, 1701336067, 8193, 499850245892, 1919248387, 8193, 7563522, 8193, 418313824004, 29697, 8193, 418296853508, 30977, 8449, 8193, 435678434052, 29185, 8193, 474299593476, 30977, 8193, 474214855428, 30977, 8449, 8449, 8449]", tokens.toString());
|
||||||
|
assertEquals(TEXT, tokenizer.detokenize(tokens, profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fallbackTokenizerGroupsWhitespace() {
|
||||||
|
final var tokenizer = new FallbackTokenizer();
|
||||||
|
final var profile = this.modelProfileService.getModelProfile(ModelCategory.MEDIUM.getModelProfile());
|
||||||
|
final var text = " ";
|
||||||
|
final var tokens = tokenizer.tokenize(text, profile);
|
||||||
|
|
||||||
|
assertEquals(2, tokens.size());
|
||||||
|
assertEquals(text, tokenizer.detokenize(tokens, profile));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tested with a Llama 3.1 8B model. The test will likely fail if you load a different model.
|
* Tested with a Llama 3.1 8B model. The test will likely fail if you load a different model.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,5 +11,3 @@ profile.model.chatcompletion=local-chatcompletion
|
|||||||
agent.threads=2
|
agent.threads=2
|
||||||
|
|
||||||
client.collectStatistics=true
|
client.collectStatistics=true
|
||||||
|
|
||||||
tokenizer.strategy=llamaServer
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
category=CHAT
|
category=CHAT
|
||||||
prompts=
|
prompts=
|
||||||
|
tokenizer=llamaServer
|
||||||
tokenEncoding=cl100k_base
|
tokenEncoding=cl100k_base
|
||||||
maxTokens=2500
|
maxTokens=2500
|
||||||
maxContextLength=16000
|
maxContextLength=16000
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
category=TEXT
|
category=TEXT
|
||||||
prompts=gpt-oss
|
prompts=gpt-oss
|
||||||
|
tokenizer=llamaServer
|
||||||
tokenEncoding=cl100k_base
|
tokenEncoding=cl100k_base
|
||||||
maxTokens=2500
|
maxTokens=2500
|
||||||
maxContextLength=16000
|
maxContextLength=16000
|
||||||
|
|||||||
Reference in New Issue
Block a user